diff --git a/.gitattributes b/.gitattributes index 6fdb58e86..8d0a3e9c6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ android/app/src/main/assets/databases/*.dbpack filter=lfs diff=lfs merge=lfs -text +Mr.Comic-debug.apk filter=lfs diff=lfs merge=lfs -text diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100644 index 000000000..ca7fcb400 --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs post-checkout "$@" diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100644 index 000000000..52b339cb3 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs post-commit "$@" diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100644 index 000000000..a912e667a --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs post-merge "$@" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 000000000..0f0089bc2 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } +git lfs pre-push "$@" diff --git a/BUGTRACKER_REDESIGN_2026-08-21.md b/BUGTRACKER_REDESIGN_2026-08-21.md new file mode 100644 index 000000000..f1a1469fc --- /dev/null +++ b/BUGTRACKER_REDESIGN_2026-08-21.md @@ -0,0 +1,149 @@ +# Bug Tracker — 2026-08-21 + +Source: `C:\Users\xmeta\.minimax\v2\assets\2026\08\21\22-56-55-091-asset_20260821-225655-091_c61d5ed3341c_2fd6bd7f-Цель.md` +Status: **NEW — not yet triaged. Acceptance criteria not met for any item.** + +## Цель + +Зафиксировать отдельным issue актуальные нерешённые дефекты Mr.Comic, выявленные по видео, прикреплённому багтрекеру и статическому анализу APK. Этот issue самостоятельный и не заменяет, не объединяет и не изменяет ранее созданные issues. + +## Актуальный scope + +В список входят **16 активных багов**. Подтверждённо исправленные симптомы в этот issue не входят: + +- автоскролл в вертикальном режиме, из-за которого появлялась лишняя пустая область/ошибка viewport; +- чёрная полоса снизу при автопрокрутке PDF/CBR; +- отображение файлов CBR как архивов RAR — подтверждено исправленным пользователем; +- crash при входе в «Настройки → Перевод → Словари» — исключён по требованию владельца текущего списка. + +## Bugs + +### Reader / vertical mode + +#### `BUG-VERTICAL-01` — Рассинхронизация фактической позиции чтения и ползунка + +**Priority:** P1 +**Area:** Reader State / Scroll / Progress + +В режиме «Вертикальная лента» положение ползунка перестаёт соответствовать фактической позиции текста. Необходимо свести состояние к цепочке `DocumentPosition → ScrollPosition → ReadingProgress → SeekBar`; ползунок не должен иметь независимый источник позиции. + +#### `BUG-PAGED-01` — Случайное выделение текста при перелистывании + +**Priority:** P2 +**Area:** Gesture / Text Selection + +Обычный swipe/tap периодически инициирует выделение текста. Выделение должно запускаться только намеренным жестом, например long press. Требуется разделить приоритеты `Tap → Long Press → Selection → Page Swipe`. + +#### `BUG-PAGED-02` — Неодинаковые верхние и нижние отступы в постраничном режиме + +**Priority:** P2 +**Area:** Layout / Pagination / Insets + +На разных страницах верхние и нижние отступы текста визуально отличаются; в отдельных форматах появляются пустые области или обрезается текст. Автоскролльный viewport defect из вертикального режима сюда не входит — он отмечен как исправленный. + +Расчёт должен быть централизованным: `Screen height − System Insets − Reader Insets − Reader Padding = Page Viewport`. + +#### `BUG-PAGED-03` — Сноски конфликтуют с зонами перелистывания + +**Priority:** P1 +**Area:** Gesture / Hit Testing / Footnotes + +Если сноска находится у края экрана и попадает в область page gesture, нажатие перехватывается перелистыванием. Приоритет hit-test должен быть: `Footnote/Link → Interactive Content → Selection → Page Navigation`. + +### Reader state / pagination / navigation + +#### `BUG-READER-01` — Некорректный подсчёт количества страниц + +**Priority:** P1 +**Area:** Pagination Engine + +Одно и то же текстовое произведение отображает разные значения общего объёма: могут смешиваться страницы главы, секции и всего документа. Pagination должна зависеть от `Document + Viewport + Font Metrics + Spacing + Padding`, а логическая позиция документа должна быть отделена от visual page count. + +#### `BUG-READER-02` — Не сохраняется выбранный режим чтения + +**Priority:** P1 +**Area:** Persistence / Reader Preferences + +После повторного открытия книги «Страницы» могут смениться на «Вертикальную ленту» или наоборот. `ReadingMode` должен храниться и восстанавливаться единообразно для каждой книги. + +#### `BUG-READER-03` — Не сохраняется фактическая позиция чтения + +**Priority:** P0 +**Area:** Persistence / Reading Position + +После выхода из книги или переключения режима пользователь возвращается не к последнему месту. Восстановление должно использовать canonical document location: section/chapter, content anchor и relative offset; visual page может быть только fallback. + +#### `BUG-READER-04` — Глобальная рассинхронизация прогресса чтения + +**Priority:** P0/P1 +**Area:** Reader State / Progress + +Chrome-панель, toolbar, информация о файле и карточка библиотеки могут показывать разные page count и проценты. Нужна единая модель `DocumentPosition → ReadingProgress 0..1 → Chrome/Toolbar/File Info/Library`, отделённая от `PaginationState`. + +#### `BUG-READER-05` — Смена режима изменяет тему-пресет + +**Priority:** P2 +**Area:** State Isolation / Theme + +Переключение «Страницы ↔ Вертикальная лента» изменяет или сбрасывает выбранный пресет. `ReadingMode` и `ReaderTheme` должны быть независимыми состояниями. + +#### `BUG-READER-06` — HTML-название книги не помещается по ширине + +**Priority:** P3 +**Area:** HTML Reader / Layout + +Длинное название книги выходит за доступную ширину и обрезается. Нужны ellipsis, перенос строк или ограничение количества строк без перекрытия соседних элементов. + +#### `BUG-READER-07` — Оглавление не работает в некоторых форматах + +**Priority:** P1 +**Area:** TOC / Document Navigation + +Оглавление отображается, но переход по некоторым главам не открывает соответствующее место документа. Нужна унификация `Format Parser → TableOfContents → DocumentLocation → Reader` для EPUB, FB2, HTML и других поддерживаемых форматов. + +### Library / visual system + +#### `BUG-UI-01` — Несогласованное оформление карточек библиотеки + +**Priority:** P2 +**Area:** Design System / Library + +Format badge и progress badge на некоторых обложках имеют недостаточный контраст; плашки используют разные формы несмотря на общую настройку скругления. Нужны единые `ShapeTokens`, `ColorTokens`, `TypographyTokens` и гарантированный контраст. + +#### `BUG-UI-02` — Некорректный пресет «День» в графическом ридере + +**Priority:** P2 +**Area:** Graphic Reader / Theme + +Пресет «День» применяется неправильно или визуально похож на другой режим. Day/Sepia/Night должны давать предсказуемые и независимые наборы фона, текста и overlay colors. + +#### `BUG-UI-04` — Неконсистентное применение цветов фона, поверхностей и карточек + +**Priority:** P1 +**Area:** Theme / Library / Contrast + +Выбранный фон применяется не ко всем поверхностям: библиотека может оставаться с blur, а отдельные элементы теряют контраст. Все reader/library surfaces должны использовать единый theme token pipeline. + +#### `BUG-UI-05` — Сломанный компонент preview кастомизации + +**Priority:** P2 +**Area:** Customization / Preview + +В preview вместо ожидаемого элемента отображается warning-like значок или некорректный placeholder. Preview должен визуально соответствовать реальному компоненту и выбранным настройкам. + +### Additional bugs confirmed by video + +#### `BUG-CANDIDATE-01` — «Цитатник» не открывает исходную страницу или якорь + +**Priority:** P1 +**Area:** Quotes / Document Location / Navigation + +Нажатие на сохранённую цитату не возвращает пользователя к точному месту, откуда она была создана. Нужно сохранять и разрешать `href`, fragment, DOM anchor, character offset или другую mode-independent document location; page number может оставаться только legacy fallback. + +## Acceptance criteria + +Для каждого бага должны быть добавлены воспроизводимый тестовый сценарий, ожидаемый результат, regression test и проверка минимум на затронутых форматах/режимах. Исправление считается готовым только после прохождения runtime-тестов на реальном Android-устройстве или emulator и проверки, что исправление не нарушает сохранение позиции, прогресс, TOC, selection и theme state. + +## Не входит в этот issue + +Этот issue не включает Dictionaries crash, исправленную чёрную полосу PDF/CBR, исправленный auto-scroll viewport defect в вертикальном режиме, исправленное отображение CBR как RAR и любые новые симптомы, которые не были подтверждены повторным воспроизведением. diff --git a/BUGTRACKER_VIDEO_2026-08-16.md b/BUGTRACKER_VIDEO_2026-08-16.md new file mode 100644 index 000000000..20062778c --- /dev/null +++ b/BUGTRACKER_VIDEO_2026-08-16.md @@ -0,0 +1,590 @@ +# Багтрекер Mr.Comic — по видеозаписи от 16 августа 2026 + +## 1. Источник и методика + +Проанализирована запись экрана `Record_2026-08-16-16-04-41_ca83fc14b354edfa350da4cec69c70da.mp4` +(длительность 39:46, 864×1920, HEVC + аудиодорожка AAC mono 48 kHz). + +Методика: + +- полная транскрипция голосовых комментариев пользователя (faster-whisper, RU); +- покадровая выборка (119 кадров, шаг 20 с) с выборочной визуальной верификацией; +- анализ аудио-таймлайна (детекция пауз/тишины: 189 событий, аномалий записи нет — + вся дорожка является авторским комментарием, TTS/аудиокниги в записи не задействованы); +- сопоставление каждого бага с **локальным кодом** репозитория + `Mr.Comic_fresh_clone`, HEAD `db1e8272` (2026-08-16 14:44); +- кросс-проверка с независимым анализом Manus (commit `1df14dd` в GitHub-зеркале). + +**Важная временная метка.** Видео записано 16.08 в 16:04, HEAD локального репозитория — +16.08 14:44, т.е. демонстрируемая сборка соответствует текущему HEAD или немного старее. +Коммит `27d30d56` (15.08, «preserve text position across mode changes») **предшествует** +записи, но потеря позиции при смене режима в видео всё равно воспроизводится — +фикс неполный либо установленная сборка старше. Требует пере-верификации на свежей сборке. + +**Классификация.** *Глобальный* баг — воспроизводится в нескольких форматах/режимах и имеет +общую причину (координаты позиции, viewport/insets, жести, тема). *Одиночный* — ограничен +конкретным форматом, экраном или лейблом. Пожелания по развитию вынесены отдельно. + +--- + +## 2. Сводная таблица + +| ID | Заголовок | Область | Severity | Priority | Подтверждение | +|---|---|---|---|---|---| +| GLOB-001 | Несогласованные координаты страницы/прогресса | Все текстовые форматы | Blocker | P0 | многократно, 03:20–20:35 | +| GLOB-002 | Потеря позиции и режима при смене режима/выходе | Текстовый reader | Critical | P0 | 14:13–18:53 | +| GLOB-003 | Высота нижней панели протекает в viewport (полоса снизу) | Все форматы, page mode + autoscroll | High | P1 | 17:06–36:24 | +| GLOB-003-V | Отступ при авточтении в ВЕРТИКАЛЬНОЙ ленте | Текстовый vertical + autoscroll | High | P1 | 17:49–18:11, кадр ~18:00 | +| GLOB-003-T | Позиция текста «гуляет» на строку сверху/снизу от страницы к странице | Все текстовые форматы | Medium | P1 | 21:14–23:10, 28:22–28:33 | +| GLOB-004 | Нестабильный page-turn: повтор/возврат/белая вспышка | Текстовый paged reader | High | P1 | 06:52–31:05 | +| GLOB-005 | Ложное выделение текста при перелистывании | Все текстовые форматы | High | P1 | 09:43, 28:12 | +| GLOB-006 | Edge-swipe перекрывает кликабельную сноску | EPUB, FB2 | High | P1 | 16:10, 21:56 | +| GLOB-007 | Поэтапная перекраска при смене темы | Глобальная тема | Medium | P1 | 36:42–37:04 | +| GLOB-008 | Custom background/surface: затемнение и плохой контраст | Библиотека, карточки | Medium | P1 | 37:15–39:43 | +| QUOTE-001 | Переход из цитатника не открывает якорь | Цитаты + навигация | High | P1 | 13:23–14:06 | +| LIB-001 | Процентный бейдж сливается с обложкой | Библиотека | Medium | P2 | 00:42–02:00 | +| LIB-002 | Галочка «прочитано» на непрочитанной книге | Библиотека | Medium | P2 | 01:56–03:00 | +| ARCH-001 | Медленное открытие RAR/CBR-архива (спиннер) | Архивы | Low | P3 | ~01:25 | +| RTF-001 | RTF: 2 из 2, 100%, ползунок в конце в начале книги | RTF | High | P1 | 03:20–05:14 | +| RTF-002 | RTF: обрезание текста снизу и скачущая высота страницы | RTF (page mode) | High | P1 | 23:51–24:26, кадр ~24:00 | +| EPUB-001 | Обложка и заголовочная страница залезают под верхний chrome | EPUB | Medium | P2 | 07:48–08:15 | +| EPUB-002 | Обычные страницы EPUB обрезаются сверху/снизу при видимом chrome | EPUB (page mode) | High | P1 | 08:21–09:42, кадр ~8:20 | +| DOCX-001 | Page mode использует ~треть высоты страницы; vertical идеален | DOCX | Medium | P2 | 24:37–26:21, кадр ~25:40 | +| HTML-001 | Заголовок обрезается справа при кастомной типографике | HTML | Medium | P2 | 26:21–27:30 | +| TXT-001 | Первая страница TXT сжата, не использует ширину/высоту | TXT | Low | P3 | 30:38–30:55 | +| FB2-001 | Одна страница FB2 на строку ниже ожидаемого | FB2 | Low | P3 | 21:14–21:47 | +| MOBI-001 | Одна страница MOBI со смещённой раскладкой | MOBI | Low | P3 | 22:16–23:10 | +| MARKDOWN-001 | Page mode: заголовок пропадает, блоки нарезаны неестественно | Markdown | Medium | P3 | 28:40–30:37, кадр ~29:20 | +| FORMAT-001 | CBR отображается как RAR | Лейбл формата | Medium | P2 | 34:53–35:14 | +| HIGHLIGHT-001 | Цветное выделение нельзя удалить: delete есть в контроллере, но нет UI-действия | Все текстовые форматы | High | P1 | 11:10–11:16, 12:11–12:28 + код | +| AUTOSCROLL-001 | Скорость автолистания: единая неперсистентная модель (⚠ частично закрыто в db1e8272) | Reader settings | Medium | P2 | 11:16–12:11 + код | +| FEATURE-001…006 | Пожелания (не баги) | — | — | Backlog | см. §6 | + +--- + +## 3. Глобальные баги + +### GLOB-001 — Единая координата страницы не соблюдается (Blocker, P0) + +**Наблюдение по видео (таймкоды):** + +- 03:20–05:14, RTF: внизу «2 из 2», в правом нижнем углу 100%, ползунок почти в конце — + при этом открыта первая страница; в библиотеке книга отображается как 100% прочитанная. + Отдельно (03:35): «в этой книге явно не тысяча страниц» — тотал в тулбаре завышен на порядки. +- 07:20–07:44, EPUB: пролистано 3 страницы — счётчик показал 7, после ещё одной — 6; + итог «6 из 91», хотя страниц в книге «намного больше». +- 18:53–20:35, EPUB: библиотека говорит 464 страницы / 63% / позиция 17; + тулбар ридера — «5 из 116», после переходов «83 из 116», «85 из 116». + Пользователь: «в два раза уменьшил размер отображаемых страниц… она, видимо, + разбивает главы кусками и показывает прогресс» — прогресс считается по chunk/section, + а не по всей книге. + +**Ожидаемое поведение.** Одна авторитетная координата чтения на книгу. UI-номер страницы, +total, слайдер, процент, флаг завершённости, сохраняемый `currentPage` и библиотечный +`readingProgress` — из одной модели. Значения монотонны, не превышают total, +100% не появляется до фактического конца. + +**Код (локально, подтверждено):** + +- `android/feature-reader/.../ui/ReaderProgressPolicy.kt:13` — `pageForPersistence(...)`; +- `android/feature-reader/.../ui/TextReaderNavigation.kt:27` — `tocDisplayPage(...)`, + `:42` — `enginePageForUiPage(...)`; +- `android/feature-reader/.../ui/TextPagePaginationController.kt`; +- EPUB chunker/progress: `EpubHtmlChunker`, EPUB page resolver, `EpubProgressCalculator`; +- `ReaderScreen.kt` (публикация `totalPages`/`currentPage` в UI). + +Параллельные представления позиции (engine page / UI page / chunk index) существуют, +но часть форматов публикует локальный chunk-индекс как глобальную страницу. RTF, +судя по «2 из 2», вообще отдаёт количество секций вместо страниц. + +**Регрессия:** RTF, EPUB (multi-spine), FB2, MOBI, HTML, TXT, Markdown, DOCX; +начало/середина/конец; page/vertical; chrome visible/hidden — сверять page, total, +percent, slider и карточку библиотеки. + +### GLOB-002 — Потеря позиции и режима чтения (Critical, P0) + +**Наблюдение:** + +- 14:13–14:47: включение вертикальной ленты после чтения в середине книги возвращает + «грубо говоря, в самое начало»; +- 15:09–15:40: смена режима «возвращает назад, не сохраняет позицию»; выход в библиотеку + и обратный вход тоже не восстанавливают прогресс; +- 17:53–18:53: в vertical режиме «пару раз страница по новой в начало перекидывала»; + после выхода/входа включился page mode вместо сохранённого vertical — сбрасывается + не только позиция, но и сам режим; +- Контроль (32:00–32:40): в PDF выход/вход восстанавливает страницу корректно — + дефект локализован в текстовом pipeline, не в платформе. + +**Ожидаемое поведение.** PAGE ↔ VERTICAL, закрытие/открытие ридера сохраняют +семантическую позицию (CFI/anchor для reflowable, абсолютная страница для raster) +и выбранный режим чтения (per-comic). + +**Код:** + +- `ReaderReadingModeController.kt`, `ReaderSessionCoordinator.kt`, + `ReaderBookOpeningController.kt`, `ReaderInteractionPolicy.kt`; +- уже есть преобразования `readerWebtoonRestoreSectionIndex`, `readerPageFromWebtoonSection`; +- `27d30d56` (15.08) добавил `ReaderPositionRestorePolicy.kt` (+160 строк тестов), + но баг воспроизводится в записи от 16.08 — **фикс неполный или сборка старше**. + Проверить порядок `save → teardown → apply mode → restore` и ключи персистентности + (comicId + format + orientation + readingMode). + +### GLOB-003 — Нижняя полоса = высота панели chrome в page mode (High, P1) + +**Наблюдение:** + +- 17:06–17:49: автопрокрутка добавляет снизу отступ «на ширину панели, в которой + кнопка Продолжить, Библиотека, Перевод и Настройки» — пагинация по высоте считается + с этим отступом; +- 31:45–33:44, PDF: чёрная полоса снизу ровно на высоту нижней панели (page mode; + в vertical — нет); +- 33:48–34:44, CBZ: та же чёрная полоса в page mode; «в текстовых форматах она белая»; +- 34:13–35:28, CBR: то же; 35:32–36:24, DJVU: то же; +- 06:25–06:47 и 08:21–09:42, EPUB: отступы сверху/снизу «задраны», «поверху и понизу» — + высота страницы меняется в зависимости от состояния chrome; +- 23:51–24:26, RTF: после каждого перелистывания текст на разной высоте, «подрезается». + +**Ожидаемое поведение.** Page viewport не зависит от видимости chrome, автопрокрутки +и нижней навигации: один владелец inset, одна вычисленная высота страницы. + +**Код:** + +- `ChromeInsetsPlan.kt` (measured/auto-hide/stable/final reserve, CSS-инсеты); +- `ReaderScreen.kt:339` — `bottomToolbarHeightPx = if (chromeIsVisible) plan.bottomChromeReservePx else 0` + (при скрытом chrome здесь 0, но полоса в видео появляется именно при скрытом chrome — + значит, reserve протекает через `stableBottomChromeReservePx`/baseline или дублируется + в CSS body inset / raster-контейнере); +- `ReaderPagedLayoutMetrics.kt`, `HtmlPageView.kt`, растровый page container; +- 27d30d56 упоминал «paged-layout shield fix» — частично, не для raster/autoscroll. + +**Тест:** матрица `chromeVisible × chromeHidden × autoScroll × PAGE/VERTICAL` для текста +и raster; высота страницы должна совпадать во всех клетках. + +### GLOB-003-V — Отступ при авточтении в вертикальной ленте (High, P1) + +**Наблюдение.** 17:49–18:11: после включения режима вертикальной ленты при активной +автопрокрутке внизу текстовой колонки появляется пустой отступ. Визуально подтверждено +на кадре ~18:00: пустая полоса ~10–15% высоты экрана между последней строкой текста +и нижним краем, под ней оверлей «17 / 464». Пользователь дополнительно подтвердил +этот баг отдельно. Там же: при включении vertical «пару раз страница по новой в начало +перекидывала» (пересекается с GLOB-002). + +**Ожидаемое поведение.** В vertical-режиме текстовая колонка занимает всю высоту +viewport; автопрокрутка не добавляет нижний отступ; контент не отскакивает в начало. + +**Код:** тот же inset-pipeline (`ChromeInsetsPlan`, webtoon/vertical container, +`TextWebtoonSessionController`), плюс логика autoscroll-высоты в vertical-режиме. +В PDF vertical полосы нет (33:27–33:36) — дефект в текстовом vertical-пути. + +### GLOB-003-T — Позиция текста «гуляет» на строку от страницы к странице (Medium, P1) + +**Наблюдение.** Пользователь прямо обобщает (28:22–28:33): «то, что разная высота +снизу, сверху, тоже ко всем [текстовым форматам] относится». Конкретные экземпляры: +FB2 21:40–21:47 («вот здесь уже неправильно, на одну строчку ниже»), MOBI 22:44–23:10 +(«кроме вот этой страницы все страницы были нормальные», «местами на одну строчку +снизу, чуть выше, хотя на одну строчку должно быть ниже»), RTF 24:17–24:26 («сверху +нормально, а снизу сейчас на одну больше»). + +**Ожидаемое поведение.** Верхняя и нижняя границы текстового блока одинаковы на всех +страницах книги (кроме, возможно, последней короткой страницы главы — слова +пользователя, 09:19–09:29). + +**Код:** `TextPagePaginationController.kt` (шаг разбиения/высота строки), +`EpubHtmlChunker`, типографика (`lineHeight`/шрифты) и скругление высоты страницы. +Не чинить формат-специфичными оффсетами — сначала общий inset/viewport (GLOB-003). + +### GLOB-004 — Нестабильный page-turn (High, P1) + +**Наблюдение:** + +- 06:52–07:03: «бывает, не с первого раза пролистывается, и страница повторяется»; +- 23:28–23:35, RTF: «я нажал пролистнуть — он меня перелистнул обратно»; +- 23:41–23:51, RTF: от первой страницы «был белый экран и потом только пролистнуло»; +- 30:55–31:05, TXT: «пытался пролистнуть — вернулось на той же странице; походу, + практически во всех текстовых форматах». + +**Ожидаемое поведение.** Один завершённый жест = максимум один переход строго `current ± step`; +отложенные колбэки старой страницы не переопределяют новое состояние; фон при загрузке +непрозрачный (нет white flash). + +**Код:** `ReaderInteractionPolicy.kt`, `PagedGesturePolicy.kt`, +`TextPagePaginationController.kt`, `ReaderPageLoader.kt`, `ReaderWebViewLoadController.kt` +(в т.ч. `readerHtmlReloadResetsScroll`, ReaderInteractionPolicy.kt:76). +История проекта уже знает white-flash/WebView-reload регрессии — проверить оставшийся путь. + +**Тест:** быстрые последовательные свайпы; колбэк старой страницы после новой; первый +переход со страницы 0; RTF/TXT-specific. + +### GLOB-005 — Ложное выделение текста при листании (High, P1) + +**Наблюдение:** 09:43–10:13: «листаю — автоматически бывает текст выделяется и появляется +менюшка выделения; оно должно быть наоборот, по выделению появляться»; 28:12–28:33 — +подтверждено как общий дефект всех текстовых форматов. + +**Ожидаемое поведение.** Горизонтальный свайп = page-turn; selection/контекстное меню — +только после long-press или явного selection-жеста; после перехода selection-меню +не открывается самопроизвольно. + +**Код:** `ReaderInteractionPolicy.kt:74` — `readerHtmlSelectionActionsEnabled(...)` +(сейчас безусловно `true`); arbitration touch-slop/long-press между +`PagedGesturePolicy.kt`, WebView native selection и `ReaderSelectionSheets`. + +### GLOB-006 — Edge-swipe перекрывает сноску (High, P1) + +**Наблюдение:** 16:10–16:53, EPUB: сноска кликабельна и работает (popup с примечанием), +но «сноска на краю страницы — не могу нажать, перелистывается страница; жест +перелистывания не должен перекрывать сноску»; 21:56–22:02 — то же в FB2. + +**Ожидаемое поведение.** Касание по anchor сноски имеет приоритет над page-turn +в его hitbox; начатый на anchor жест не листает страницу. + +**Код:** `ReaderFootnoteAnchorPolicy.kt`, `ReaderFootnotePopupPolicy.kt`, +`PagedGesturePolicy.kt` (edge tap zones), `HtmlPageView.kt` (WebView bridge, +hit-testing DOM anchor vs Compose gesture layer). +Тест: anchor в 0–10% и 90–100% ширины viewport. + +### GLOB-007 — Поэтапная перекраска темы (Medium, P1) + +**Наблюдение:** 36:42–37:04: sepia → dark → light — «интерфейс перестраивается, хотя +должен мгновенно меняться, и не должно быть видно, что он перестраивается кусками». + +**Код:** `core-ui/.../theme/ThemePreferencesRepository.kt` — набор раздельных +`setThemeMode/setThemePreset/setCustomPrimaryColor/...` (строки 61–106+), каждый пишет +свой ключ DataStore; `themeConfig` собирается из многих ключей → промежуточные эмиции; +`MainActivity.kt:133` (`collectAsState`) рисует смешанные кадры. + +**Фикс:** публиковать один immutable `ThemeConfig` (versioned snapshot preset), +записывать атомарно; screenshot-тест переключения. + +### GLOB-008 — Custom background/surface: затемнение и контраст (Medium, P1) + +**Наблюдение (37:15–39:43):** + +- выбранный синий «фон экрана» не применился к экрану библиотеки — «стал только тёмным, + как будто заблюренный»; после выключения эффекта — «здесь всё нормально»; +- «поверхности карточек, панели — всё покрасилось, но кнопка "Продолжить" почему-то + тёмная на синем фоне» (плохой контраст выбранного состояния); +- цвета «кислотные»; пожелание — пастельные/акцентные либо убрать настройку, + оставив только прозрачность (см. FEATURE-004). + +**Код:** `core-ui/.../theme/Theme.kt` (построение `surfaceVariant/surfaceDim/surfaceContainer*` +через `lerp` от custom-цветов + alpha + `contrastingOnColor()`), `ThemePreferencesRepository.kt`, +`feature-settings/.../SettingsAppearanceTheme.kt`, `ContinueGamificationCards.kt` +(кнопка «Продолжить»). +Фикс: contrast-аудит ролей контейнеров + выбранных состояний; детерминированная +трансформация пользовательского цвета вместо чистого lerp. + +--- + +## 4. Одиночные баги + +### QUOTE-001 — Переход из цитатника не открывает якорь (High, P1) + +13:23–14:06: сохранённая цитата при нажатии «не сработала» — книга должна открываться +на странице-якоре цитаты независимо от режима (page/vertical). + +**Код:** `AppNavigation.kt:414–421` — `onQuoteClick` передаёт только числовой `page` +в `Screen.Reader.createForComic(...)`; для текстовых форматов числовой индекс нестабилен +(GLOB-001), locator не передаётся вовсе. Сохранение: `ReaderSaveQuoteController.kt` +(цитата хранит `comicId`, `page`, `contentHash` — см. `SavedQuote.kt`). +Фикс: хранить и передавать locator/CFI якоря, fallback на page только для raster. + +### LIB-001 — Процентный бейдж сливается с обложкой (Medium, P2) + +00:42–02:00 (подтверждено и кадрами): на светлых/ярких обложках «облачко» с процентом +не читается; требование пользователя — полностью белый фон бейджа, чёрный текст. + +**Код (корень подтверждён):** `core-ui/.../designsystem/Chips.kt:117` — тон `Info`: +`containerColor = colorScheme.primary.copy(alpha = 0.14f)` — полупрозрачный; +вызов — `feature-library/.../components/ComicGridItem.kt:315` (`MrComicStatusBadge` +для READING/NEW). Завершённый бейдж тоже полупрозрачный: `completedColor.copy(alpha = 0.18f)` +(ComicGridItem.kt:306). +Фикс: непрозрачный белый контейнер + чёрный текст (зелёный текст при 100% — по желанию +пользователя, FEATURE-003). + +### LIB-002 — Галочка «прочитано» на непрочитанной книге (Medium, P2) + +01:56–03:00: значок прочитанного (CheckCircle) поверх облачка с процентами у книги, +которая не прочитана. Бейдж `CheckCircle + "100%"` показывается при +`readingStatus() == COMPLETED` (ComicGridItem.kt:301–318). + +**Корень:** `core-model/.../Comic.kt:59–80` — `completedByProgress = normalizedProgress >= 0.999f`; +при ложных 100% из GLOB-001 (RTF «2 из 2» → progress = 1.0) книга получает COMPLETED. +Комментарий в коде уже запрещает завершение по факту «стояния на последней странице», +но лживый progress ≥ 0.999 всё ещё даёт ложный COMPLETED. Чинить в связке с GLOB-001; +UX-предложение пользователя (зелёная полоса под обложкой) — FEATURE-003. + +### ARCH-001 — Медленное открытие RAR/CBR-архива со спиннером (Low, P3) + +~01:25: при открытии CBR/RAR-архива в библиотеке длительная загрузка с индикатором +(зафиксировано в независимой хронологии анализа; пользователь сказал «он открывается, +но потом к нему вернёмся» и вернулся к CBR только на 34:53). Требует замера: сколько +секунд занимает открытие и сколько — кэшированное повторное. + +**Код:** `ArchiveDelegatingFormatReader.kt` (lazy delegate + извлечение во temp), +junrar-путь, `FormatDetector`. Возможные меры: async-открытие с progress-UI, +кэш списка файлов архива. + +### RTF-001 — Неверный total/page/progress в RTF (High, P1) + +03:20–05:14: «2 из 2», 100%, ползунок в конце, ~1000 страниц в тулбаре при малом документе. +Самый наглядный экземпляр GLOB-001: похоже, `totalPages` берётся из количества секций, +а не страниц. + +**Код:** `FormatFactory.kt` → RTF в `TextFormatReader`; `TextPagePaginationController.kt`; +публикация `totalPages` в `ReaderUiState`. Нужен fixture с известным количеством страниц. + +### RTF-002 — RTF: обрезание текста снизу и разная высота страницы при каждом листании (High, P1) + +23:51–24:26: «текст намного ниже, слишком низко, и он ПОДРЕЗАЕТСЯ, но он вообще не должен +на такой высоте быть… при каждом перелистывании он каждый раз ПО-РАЗНОМУ отображает высоту, +что сверху, что снизу; сверху нормально, а снизу сейчас на одну больше». +Сюда же из этого же фрагмента: 23:25–23:32 нажал перелистнуть — «перелистнул обратно» +(GLOB-004); 23:41–23:51 от первой страницы «был белый экран и потом только пролистнуло». + +**Визуально подтверждено** (кадр ~24:00): верхний отступ текста всего ~5–8% высоты +экрана, нижний ~0%, последняя строка обрезана примерно наполовину высоты строки; +счётчик внизу «2 / 2». + +**Ожидаемое поведение.** Одинаковые поля на всех страницах; строки не обрезаются; +высота блока стабильна между перелистываниями. + +**Код:** RTF → HTML конверсия, `TextPagePaginationController.kt`, CSS body inset +(`ChromeInsetsPlan.textContentBottomInsetCssPx`) и `HtmlPageView.kt`. Вероятно, +пагинация RTF считает высоту viewport без учёта inset/scroll и отдаёт WebView +больше строк, чем видно. + +### EPUB-001 — Обложка и заголовочная страница залезают под верхний chrome (Medium, P2) + +07:48–08:15: «обложка слишком высоко задрана, она должна быть чуть ниже, чтобы она +не задевала этот тулбар»; 08:53–09:16: титульная страница — «вот сейчас как должно +быть, чтобы оно не задевало, вот так это стандарт». + +**Код:** EPUB cover/титул в chunker (`EpubHtmlChunker`), CSS body inset injection, +`HtmlPageView.kt`. + +### EPUB-002 — Обычные страницы EPUB обрезаются сверху и снизу при видимом chrome (High, P1) + +08:21–09:42: «тут задевает, тут тоже неправильно… оно слишком задрано ВСЁ, и ПОВЕРХУ, +и ПОНИЗУ… масштабирование по высоте — пагинация почти правильная, но ещё чуть-чуть +надо подработать, чтобы высота была одинаковая вне зависимости от режима скрытия +тулбаров… опять всё вверх задевает, неправильная пагинация, масштабирование точнее; +здесь так же, здесь так же». Стандарт пользователя (09:19–09:29): одинаковые отступы +на ВСЕХ страницах, кроме последней короткой страницы главы. + +**Визуально подтверждено** (кадр ~8:20): первая строка обычной текстовой страницы +частично уходит под верхний тулбар, верхнее поле ~3–5% высоты экрана. + +**Код:** `ChromeInsetsPlan` (двойное применение inset — при чанкинге страницы и при +отрисовке WebView), `ReaderScreen.kt:339`, `ReaderPagedLayoutMetrics.kt`, +`EpubHtmlRenderer`. Высота viewport для пагинации не совпадает с фактической +высотой WebView при видимом chrome. + +### DOCX-001 — Page mode использует ~треть высоты страницы; vertical идеален (Medium, P2) + +24:37–26:21: в vertical «прям вообще идеально… очень классно работает и отображается +правильно»; в page mode «он уже неправильно отображается… как минимум вот эта вот +часть, вот верхние два абзаца, вот эти заголовки и вот этот абзац можно было поместить +на одну страницу… тут уже можно было вот так разбить… в формате DOCX починить только +режим страницы». + +**Визуально подтверждено** (кадр ~25:40): страница «Fun and Fonts / Spacing» занята +текстом лишь на ~30–35% высоты, 65–70% — пустота; следующий блок поместился бы. + +Примечание 25:01: «почему-то он не листается… а, это режим вертикальной ленты» — +кнопка/жест перелистывания в vertical-режиме не действует (пользователь сам понял, +что находился в ленте). Отметить как UX: в vertical-режиме tap-зоны/кнопка листания +не должны дезориентировать. + +**Код:** DOCX → HTML, `TextPagePaginationController.kt` (правила разбиения блоков, +keep-with-next, min-fill страницы в page mode). Fixture: один документ в обоих режимах. + +### HTML-001 — Заголовок обрезается справа при кастомной типографике (Medium, P2) + +26:21–27:30: «заголовок слишком куда-то уходит вправо, потому что буковки должны +влезать вне зависимости от того, выставлены у меня настройки масштабирования шрифта, +межбуквенного, межабзацного, межсловного интервала; текст должен помещаться вне +зависимости от размера экрана, он должен масштабироваться под экран». В page mode — +«не считая того, что заголовок с буквами не полностью входит» (27:26). + +**Код:** HTML sanitizer/CSS builder (`UnifiedReaderCssBuilder`, `ReaderHtmlCssTest`), +`HtmlPageView.kt`. Проверить `overflow-wrap:anywhere`, `max-width:100%`, box-sizing +и наложение пользовательской типографики на заголовки. +Минор-пожелания (27:36–28:02): TOC по центру, chapter начинать с новой страницы +(FEATURE-006). + +### TXT-001 — Первая страница TXT сжата, не использует страницу (Low, P3) + +30:38–30:55: «не знаю, здесь нормально или нет — по-моему, слишком сильно сжато, +можно было на всю страницу всё это аккуратненько распределить красиво; здесь +нормально». При этом стили и vertical mode хвалятся (31:19–31:33: «просто офигенно, +отражаются стили»). + +**Код:** plain-text→HTML builder в `TextFormatReader`, CSS `pre`/`white-space`, +горизонтальные padding/margin, `TextPagePaginationController.kt` (min-fill). + +### FB2-001 — Одна страница FB2 на строку ниже ожидаемого (Low, P3) + +21:14–21:47: «отступы правильные… первая страница ладно, она как начало… и здесь +они сейчас правильно идут, здесь правильно, и вот здесь уже неправильно — на одну +строчку ниже; здесь правильно, тут правильно». Плюс сноска у края (GLOB-006, 22:00). +Частный случай GLOB-003-T. + +### MOBI-001 — Одна страница MOBI со смещённой раскладкой (Low, P3) + +22:16–23:10: «отступы нормальные, здесь тоже, здесь тоже… в формате MOBI нормально +всё, кроме вот этой страницы»; 23:03: «местами на одну строчку снизу, чуть выше, +хотя на одну строчку должно быть ниже». Частный случай GLOB-003-T. + +### MARKDOWN-001 — Page mode: заголовок пропадает, блоки нарезаны неестественно (Medium, P3) + +28:40–30:37: в vertical — «идеально, идеально, идеально… отображение нормальное»; +в page mode — «в начале был вот этот заголовочек, его сверху нету… Introduction +смещается вот сюда… About the document становится вот так… Leave the blocks на +следующую страницу переходит… он неправильно бьётся, разбивается как-то странно, +я не знаю, может быть это нормально». + +**Визуально подтверждено** (кадр ~29:20, page mode): страница начинается сразу +с «Introduction» — заголовок «What is this?» вверху ОТСУТСТВУЕТ; H3-заголовки +прижаты к предыдущему блоку, разбиение неестественное. + +**Код:** Markdown → HTML, `TextPagePaginationController.kt`: заголовок «съедается» +при разбиении (вероятно, остаётся на предыдущей странице-границе блока) — проверить +keep-with-next для heading и отображение первого заголовка страницы. + +### FORMAT-001 — CBR отображается как RAR (Medium, P2) + +34:53–35:14: «почему-то помечается как RAR, но он ничерта не RAR… должен подписываться +как CBR, а не RAR». + +**Код (корень найден точно):** `engine-formats/.../archive/ArchiveDelegatingFormatReader.kt:520`: + +```kotlin +ComicFormat.CBR, ComicFormat.RAR -> "rar" +``` + +лейбл возвращает `"rar"` для обоих форматов. Детекция в `FormatFactory.kt` +(`archiveContainerFormatFromPath`) различает cbr/rar корректно — ошибка только в лейбле. +Фикс: `ComicFormat.CBR -> "cbr"` (+ тест). + +### HIGHLIGHT-001 — Цветное выделение создаётся, но удалить его из UI нельзя (High, P1) + +Выделение работает: 12:11–12:28 — «Подсветить» → выбор цвета → подсвечивается и +сохраняется. Но удалить созданную пометку из читалки нельзя; пользователь прямо просит +(11:10–11:16): «если я книгу какую-то читаю, я мог эту пометку из текста удалить, убрать». + +**Код (проверено локально):** операция удаления существует во всей цепочке, кроме UI: + +- `ReaderHighlightController.kt:84` — `deleteHighlight(id)` → репозиторий → DAO; +- `TextHighlightRepository.kt:36` и `TextHighlightDao.kt:36` — delete есть; +- но `ReaderSelectionSheets.kt` (`SelectedTextActionSheet`) предлагает только + Перевести (`:66`) и Объяснить (`:80`); `HighlightColorPickerSheet` — только выбор цвета; +- вызовов `highlightController.deleteHighlight(...)` из `ReaderScreen.kt` / + `ReaderBottomSheets.kt` / `ReaderSelectionSheets.kt` нет вообще. + +Дефект — UI wiring: нет способа выбрать существующий highlight (tap по `` с +`dataset.highlightId`, см. `HighlightJsGenerator.kt:47`) и передать его id в контроллер. +Тесты `HighlightJsGeneratorTest` и `ReaderHighlightRuntimeControllerTest` в HEAD уже есть +(утверждение внешнего аудита об их отсутствии устарело) — добавить тест сценария удаления. + +### AUTOSCROLL-001 — Модель скорости автолистания (Medium, P2; ⚠ частично закрыто в HEAD) + +Запрос пользователя (11:16–12:11): раздельные настройки — секунды на страницу (page mode, +на видео виден отсчёт «12.0 с/стр») и пиксели в секунду (vertical, «45 px/s»). + +**Состояние в локальном HEAD `db1e8272` (проверено):** `ReaderAutoScrollProfiles.kt` уже +реализует раздельные по режимам скорости с персистентностью (precise-ключи +`reader_auto_scroll_precise_speed_*_v2` для PAGE_LTR/PAGE_RTL/DUAL_PAGE/WEBTOON, +legacy-миграция с пресетов 30/80/180, `restoreSpeedFor(mode)` без автостарта, +`autoScrollCountdownProgress` — отсчёт для page mode). Утверждение внешнего аудита +о единственной неперсистентной `autoScrollSpeed: Float` верно для коммита `1df14dd`, +но устарело для HEAD. + +**Остаток:** UX-доступ из «Сервисов» (FEATURE-001A) и явные единицы «сек/страница» +для paged-режима; убедиться, что значения переживают смену режима и переоткрытие книги. + +--- + +## 5. Манифестации GLOB-003 в графических форматах (не отдельные баги) + +| Формат | Время | Проявление | +|---|---|---| +| Текст + autoscroll, page mode | 17:06–17:49 | нижний отступ = высота панели; «пагинация по высоте неправильно рассчитывается» | +| Текст + autoscroll, vertical | 17:49–18:11 (кадр ~18:00) | пустая полоса ~10–15% высоты внизу колонки (см. GLOB-003-V) | +| PDF + autoscroll | 31:45–33:44 | чёрная полоса снизу = высота нижней панели; в vertical нет | +| CBZ | 33:48–34:44 | чёрная полоса в page mode; «в текстовых — белая» | +| CBR | 34:13–35:28 | то же | +| DJVU | 35:32–36:24 | «отступ, которого не должно быть в режиме страницы, что в текстовом ридере, что в графическом» | +| EPUB (текст) | 06:25–09:42 | «задрано» поверху/понизу при видимом chrome (см. EPUB-001/002) | +| RTF (текст) | 23:51–24:26 | текст «слишком низко… подрезается», высота меняется каждое листание (см. RTF-002) | + +Чинить одним общим фиксом viewport/inset, не отдельными патчами. +Положительный контроль: 32:00–32:40 — в PDF сохранение позиции работает (контраст +к GLOB-002 в текстовых форматах); в PDF vertical полосы нет (33:27–33:36). + +## 6. Пожелания (не баги) + +| ID | Запрос | Таймкод | +|---|---|---| +| FEATURE-001 | В «Сервисах» ридера: управление выделенным текстом и заметками (смена цвета, список пометок). Удаление пометки — не пожелание, а обязательный баг HIGHLIGHT-001 | 10:24–11:16 | +| FEATURE-002 | В «Сервисах»: доступ к скорости автопрокрутки (page: сек/стр; vertical: px/s). Раздельная персистентная модель уже есть — `ReaderAutoScrollProfiles.kt` (AUTOSCROLL-001), нужен UI | 11:21–12:11 | +| FEATURE-003 | Прочитанная книга: зелёное подчеркивание края обложки + зелёный текст процентов вместо галочки | 02:29–03:00 | +| FEATURE-004 | Custom colors: пастельные не-кислотные тона или убрать настройку, оставив только прозрачность | 38:48–39:29 | +| FEATURE-005 | Цитатник: группировка/колонки | 12:35–12:57 | +| FEATURE-006 | HTML TOC по центру; chapter с новой страницы | 27:36–28:02 | + +## 7. Рекомендуемый порядок исправления + +1. **GLOB-001** — авторитетная координата позиции (semantic anchor/CFI для reflowable, + абсолютная страница для raster); унифицировать total/progress/completion/UI. + Снимает RTF-001, LIB-002 и большую часть «прыгающих» счётчиков. +2. **GLOB-002** — транзакционная смена режима и reopen per-comic (позиция + режим). + Сначала пере-верифицировать `27d30d56` на свежей сборке. +3. **GLOB-003 / GLOB-003-V / GLOB-003-T** — один владелец bottom inset; отвязать высоту + страницы от chrome/autoscroll; стабильная высота текстового блока на всех страницах. + Снимает полосы PDF/CBZ/CBR/DJVU, отступ при авточтении в вертикальной ленте, + обрезание и «задранные» отступы EPUB-002/RTF-002, смещения на строку FB2/MOBI. +4. **GLOB-004/005/006** — gesture arbitration (page-turn vs selection vs footnote anchor) + с приоритетами и cancellation. +5. **GLOB-007/008** — атомарный ThemeConfig + contrast-аудит. +6. Форматные: FORMAT-001 (однострочный фикс лейбла), QUOTE-001 (locator в навигации), + затем DOCX/HTML/TXT/Markdown верстка. + +## 8. Регрессионная матрица (критерии готовности) + +- Форматы: EPUB, FB2, MOBI, RTF, DOCX, HTML, Markdown, TXT, PDF, CBZ, CBR, DJVU. +- Режимы: page / vertical; chrome visible / hidden; autoscroll off / on. +- Жизненный цикл: open → page-turn ×N → mode switch → exit → reopen → rotation. +- Инварианты: одна и та же семантическая позиция сохраняется; `0 ≤ page < total`; + progress не скачет назад; completion только на фактическом конце; высота страницы + не зависит от chrome/autoscroll; тема меняется одним кадром; CBR ≠ RAR. +- Жесты: быстрые последовательные свайпы; long-press selection; edge-footnote tap + (0–10% и 90–100% ширины). + +## 9. Что проверено не было + +- Эмулятор/сборка: баги зафиксированы по записи пользователя; воспроизведение на + текущем HEAD не выполнялось (временно́й бюджет сессии). +- TTS/аудиокниги: в записи не демонстрировались — по аудио аномалий нет + (дорожка = голосовой комментарий; фрагменты сказочного текста на 14:47–16:10 — + фоновое чтение, не ошибка приложения). +- Визуальная верификация выполнена для ключевых моментов с учётом лимитов сервиса + анализа изображений: библиотека (бейджи, ~0:40–1:00), вертикальная лента + с авточтением (~18:00), RTF-обрезание (~24:00), DOCX page mode (~25:40), + EPUB-обрезание (~8:20), Markdown page mode (~29:20), TXT (~30:40 — момент сжатия + первой страницы кадром не пойман, зафиксирован по транскрипции). + Остальные визуальные выводы опираются на полную транскрипцию и независимый + анализ Manus. + +## 10. Артефакты анализа + +- Кадры: `scratch/record2/frames/` (119 шт., шаг 20 с), монтажи `scratch/record2/montage/`. +- Аудио/тишина: `scratch/record2/audio/full.wav`, `silence.txt`. +- Своя транскрипция: `scratch/record2/audio/transcript.txt` (faster-whisper, RU). +- Внешние материалы пользователя (Desktop): полная транскрипция, хронология + наблюдений, независимый багтрекер Manus (commit `1df14dd`) и объединённый + трёхпроходный аудит `Mr.md` (аудио + кадры + код), использованные для + кросс-проверки; расхождения с ними отмечены по тексту: + `ReaderContentArea.kt` в локальном дереве отсутствует (актуальный контейнер — + `ReaderContainerHost.kt`); тесты highlight в HEAD уже есть; модель autoscroll + уже раздельная по режимам (`ReaderAutoScrollProfiles.kt`). diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 000000000..c35306fa6 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,255 @@ +# Сводка реализации: Расширение системы геймификации Mr.Comic + +## 🎯 Цель +Расширить систему геймификации Mr.Comic, добавив систему достижений, еженедельные челленджи, улучшенную визуализацию и новые мотивирующие элементы. + +## ✅ Реализованные компоненты + +### 1. Система достижений + +#### Модели данных (`core-model`) +- **AchievementModels.kt** - Основные модели: + - `Achievement` - модель достижения + - `AchievementRequirement` - требования для получения + - `AchievementProgress` - прогресс достижения + - `UserAchievements` - состояние всех достижений + - `AchievementNotification` - уведомления + +- **AchievementDefinitions.kt** - Определения достижений: + - 30+ достижений в 5 категориях + - 5 уровней редкости (Common → Legendary) + - Секретные достижения + +#### Трекер достижений (`core-domain`) +- **AchievementTracker.kt** - Основной трекер: + - Отслеживание прогресса по всем метрикам + - Автоматическая разблокировка достижений + - Уведомления о новых достижениях + - Расчёт прогресса (0.0 - 1.0) + +#### Интеграция (`core-domain`) +- **GamificationIntegration.kt** - Интеграция с существующими системами: + - Связь с DailyReadingGoalStore + - Связь с MascotProgressCalculator + - Обновление прогресса в реальном времени + +### 2. Еженедельные челленджи + +#### Модели данных (`core-model`) +- **WeeklyChallengeModels.kt** - Модели челленджей: + - 6 типов челленджей + - Автоматическое обновление прогресса + - Награды XP за выполнение + +#### Трекер челленджей (`core-domain`) +- **WeeklyChallengeTracker.kt** - Трекер челленджей: + - Отслеживание прогресса по неделям + - Автоматическое завершение + - Сброс в начале новой недели + +### 3. UI компоненты + +#### Карточки достижений (`core-ui`) +- **AchievementCard.kt** - Карточка достижения: + - Анимированный прогресс-бар + - Иконки статуса + - Награда XP + +- **AchievementNotification.kt** - Уведомления: + - Всплывающие уведомления + - Автоматическое скрытие + - Анимации появления/исчезновения + +#### Экраны достижений (`core-ui`) +- **AchievementListScreen.kt** - Список достижений: + - Группировка по категориям + - Общая статистика + - Фильтрация по статусу + +- **AchievementDetailScreen.kt** - Детали достижения: + - Подробная информация + - Прогресс-бар + - История получения + +#### Графики чтения (`core-ui`) +- **ReadingCharts.kt** - Визуализация: + - График активности за неделю + - Тепловая карта за месяц + - Легенда и подписи + +#### Еженедельные челленджи (`core-ui`) +- **WeeklyChallengeCard.kt** - Карточка челленджа: + - Прогресс-бар + - Статус выполнения + - Награда XP + +- **WeeklyChallengesScreen.kt** - Экран челленджей: + - Список всех челленджей + - Прогресс по каждому + - Общая статистика + +#### Экран статистики (`core-ui`) +- **GamificationStatsScreen.kt** - Общая статистика: + - Общие метрики + - Ежедневные цели + - Превью достижений + - Прогресс маскота + +### 4. ViewModel (`feature-settings`) +- **GamificationViewModel.kt** - Управление состоянием: + - Загрузка данных + - Обновление прогресса + - Управление уведомлениями + +## 📊 Архитектура + +``` +┌─────────────────────────────────────────────────────────────┐ +│ UI Layer (Compose) │ +│ • AchievementCard, AchievementNotification │ +│ • AchievementListScreen, AchievementDetailScreen │ +│ • WeeklyChallengeCard, WeeklyChallengesScreen │ +│ • GamificationStatsScreen, ReadingCharts │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Domain Layer (Business Logic) │ +│ • AchievementTracker │ +│ • WeeklyChallengeTracker │ +│ • GamificationIntegration │ +│ • MascotProgressCalculator (existing) │ +│ • DailyReadingGoalStore (existing) │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Data Layer (Persistence) │ +│ • AchievementModels, AchievementDefinitions │ +│ • WeeklyChallengeModels, WeeklyChallengeDefinitions │ +│ • PreferencesKeys (existing) │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 🎮 Система достижений + +### Категории достижений +1. **READING** - За чтение (100, 500, 1000, 5000, 10000 страниц) +2. **COLLECTION** - За коллекцию (1, 5, 10, 25, 50 тайтлов) +3. **STREAK** - За серии (3, 7, 14, 30, 100 дней) +4. **EXPLORATION** - За исследование (марафон, чемпион недели, время чтения) +5. **MILESTONE** - Вехи (стадии маскота, XP) + +### Редкость достижений +- **COMMON** - Обычные (10-50 XP) +- **UNCOMMON** - Необычные (50-100 XP) +- **RARE** - Редкие (100-200 XP) +- **EPIC** - Эпические (200-500 XP) +- **LEGENDARY** - Легендарные (500-1000 XP) + +## 🏆 Еженедельные челленджи + +### Типы челленджей +1. **PAGES_READ** - Прочитать X страниц +2. **TITLES_COMPLETED** - Завершить X тайтлов +3. **STREAK_DAYS** - Поддерживать серию X дней +4. **READING_TIME** - Провести X минут за чтением +5. **DAILY_GOAL** - Выполнить ежедневную цель X дней +6. **NEW_GENRE** - Прочитать тайтл нового жанра + +## 📈 Метрики и статистика + +### Отслеживаемые метрики +- Страниц прочитано +- Тайтлов завершено +- Серия дней +- Время чтения +- XP заработано +- Стадия маскота +- Ежедневные цели +- Еженедельные челленджи + +### Визуализация +- Графики активности за неделю +- Тепловые карты за месяц +- Прогресс-бары для достижений +- Анимации при повышении уровня + +## 🔧 Интеграция + +### Существующие системы +1. **DailyReadingGoalStore** - Ежедневные цели и серии +2. **MascotProgressCalculator** - XP и стадии маскота +3. **PreferencesKeys** - Хранение настроек + +### Новые компоненты +1. **AchievementTracker** - Трекер достижений +2. **WeeklyChallengeTracker** - Трекер челленджей +3. **GamificationIntegration** - Интеграция всех систем + +## 🚀 Следующие шаги + +### Phase 5: Тестирование и интеграция +- [ ] Unit тесты для AchievementTracker +- [ ] Unit тесты для WeeklyChallengeTracker +- [ ] Интеграционные тесты +- [ ] UI тесты +- [ ] Финальная интеграция в приложение + +### Возможные улучшения +1. **Персонализированные рекомендации** - На основе истории чтения +2. **Социальные функции** - Сравнение с друзьями +3. **Дополнительные достижения** - За разнообразие жанров +4. **Анимации** - Более сложные анимации при повышении уровня +5. **Звуковые эффекты** - Звуки при разблокировке достижений + +## 📁 Созданные файлы + +### core-model +- `AchievementModels.kt` +- `AchievementDefinitions.kt` +- `WeeklyChallengeModels.kt` + +### core-domain +- `AchievementTracker.kt` +- `WeeklyChallengeTracker.kt` +- `GamificationIntegration.kt` + +### core-ui +- `AchievementCard.kt` +- `AchievementNotification.kt` +- `AchievementListScreen.kt` +- `AchievementDetailScreen.kt` +- `WeeklyChallengeCard.kt` +- `WeeklyChallengesScreen.kt` +- `GamificationStatsScreen.kt` +- `ReadingCharts.kt` + +### feature-settings +- `GamificationViewModel.kt` + +### Документация +- `task_plan.md` +- `findings.md` +- `progress.md` +- `IMPLEMENTATION_SUMMARY.md` + +## ✅ Статус реализации + +| Фаза | Статус | Описание | +|------|--------|----------| +| Phase 1 | ✅ complete | Анализ и проектирование | +| Phase 2 | ✅ complete | Реализация системы достижений | +| Phase 3 | ✅ complete | Улучшение визуализации | +| Phase 4 | ✅ complete | Новые мотивирующие элементы | +| Phase 5 | 🔄 in_progress | Тестирование и интеграция | + +## 🎉 Заключение + +Система геймификации Mr.Comic значительно расширена: + +1. **Система достижений** - 30+ достижений в 5 категориях с разными уровнями редкости +2. **Еженедельные челленджи** - 6 типов челленджей с наградами XP +3. **Улучшенная визуализация** - Графики, тепловые карты, анимации +4. **Полная интеграция** - Связь с существующими системами геймификации +5. **Material 3 дизайн** - Соответствие современным стандартам Android + +Система готова к тестированию и интеграции в основное приложение. diff --git a/Mr.Comic-debug.apk b/Mr.Comic-debug.apk new file mode 100644 index 000000000..0e06de401 --- /dev/null +++ b/Mr.Comic-debug.apk @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb153f05cd2fa2c5a6fdbb3139acfb9ead81c1d2e062875bce1631c22f1b152b +size 298314813 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 20c835398..f5f47eafa 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -41,8 +41,8 @@ android { applicationId = "io.leostrange.mrcomic" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 4 - versionName = "2.4.0" + versionCode = 5 + versionName = "2.5.0" buildConfigField("String", "GIT_SHA", "\"$gitSha\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } @@ -85,12 +85,6 @@ android { } kotlinOptions { jvmTarget = libs.versions.jvmTarget.get() } buildFeatures { compose = true; buildConfig = true } - lint { - // The base strings resource is intentionally Russian while locale packs are partial. - // Keep all other lint checks strict; translation completeness is tracked separately. - disable += "MissingTranslation" - } - androidResources { // The packaged Room dictionary has grown beyond what AAPT compression handles reliably. // The shipped dictionaries are precompressed under a custom extension; keep them as-is. diff --git a/android/app/src/main/assets/fonts/AccessibleDfA.otf b/android/app/src/main/assets/fonts/AccessibleDfA.otf new file mode 100644 index 000000000..69c02218a Binary files /dev/null and b/android/app/src/main/assets/fonts/AccessibleDfA.otf differ diff --git a/android/app/src/main/assets/fonts/LiberationSans-Regular.ttf b/android/app/src/main/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 000000000..e6339859d Binary files /dev/null and b/android/app/src/main/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/android/app/src/main/assets/fonts/OpenDyslexic-Regular.otf b/android/app/src/main/assets/fonts/OpenDyslexic-Regular.otf new file mode 100644 index 000000000..1226d2ab2 Binary files /dev/null and b/android/app/src/main/assets/fonts/OpenDyslexic-Regular.otf differ diff --git a/android/app/src/main/assets/fonts/iAWriterDuospace-Regular.ttf b/android/app/src/main/assets/fonts/iAWriterDuospace-Regular.ttf new file mode 100644 index 000000000..7e75aa351 Binary files /dev/null and b/android/app/src/main/assets/fonts/iAWriterDuospace-Regular.ttf differ diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/ComicApplication.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/ComicApplication.kt index 5946ebec2..98cf2f48f 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/ComicApplication.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/ComicApplication.kt @@ -3,6 +3,8 @@ package io.leostrange.mrcomic import android.app.Application import android.content.ComponentCallbacks2 import android.util.Log +import coil.ImageLoader +import coil.ImageLoaderFactory import io.leostrange.mrcomic.core.data.preferences.PerformanceDefaults import io.leostrange.mrcomic.core.data.preferences.PerformancePreferencesKeys import io.leostrange.mrcomic.core.data.preferences.UserPreferences @@ -23,9 +25,20 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltAndroidApp -class ComicApplication : Application() { +class ComicApplication : Application(), ImageLoaderFactory { companion object { private const val TAG = "ComicApp" } + /** + * STYLE-SMOOTH: one global Coil loader with a gentle 180ms crossfade so every + * AsyncImage in the app fades covers in instead of popping them in. Coil 2 has + * no per-call crossfade parameter on AsyncImage — the canonical place for it is + * the shared ImageLoader. + */ + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .crossfade(180) + .build() + var splashPlayedInProcess: Boolean = false @Inject diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/backup/AutoBackupManager.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/backup/AutoBackupManager.kt index 9b8d164c8..d62cca80c 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/backup/AutoBackupManager.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/backup/AutoBackupManager.kt @@ -7,6 +7,7 @@ import io.leostrange.mrcomic.core.data.preferences.PreferencesKeys import io.leostrange.mrcomic.core.data.preferences.UserPreferences import io.leostrange.mrcomic.core.data.preferences.dataStore import io.leostrange.mrcomic.core.model.repository.LibraryRepository +import io.leostrange.mrcomic.core.model.displayReadingProgress import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -75,7 +76,7 @@ class AutoBackupManager @Inject constructor( put("title", comic.title) put("currentPage", comic.currentPage) put("pageCount", comic.pageCount) - put("readingProgress", comic.readingProgress.toDouble()) + put("readingProgress", comic.displayReadingProgress().toDouble()) put("lastReadDate", comic.lastReadDate ?: 0L) put("isBookmarked", comic.isBookmarked) put("isCompleted", comic.isCompleted) diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueReadingCards.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueReadingCards.kt index 5004a49f2..62cacf89d 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueReadingCards.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueReadingCards.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.ui.designsystem.MrComicButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicButtonVariant import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface @@ -330,7 +331,7 @@ internal fun ContinueReadingCard( contentColor = MaterialTheme.colorScheme.onSurface ) { Text( - text = text.pageProgress(comic.currentPage + 1, (comic.readingProgress * 100).toInt()), + text = text.pageProgress(comic.currentPage + 1, (comic.displayReadingProgress() * 100).toInt()), style = MaterialTheme.typography.labelMedium ) } @@ -371,9 +372,9 @@ internal fun ContinueComicCard( maxLines = 2, overflow = TextOverflow.Ellipsis ) - if (comic.readingProgress > 0f) { + if (comic.displayReadingProgress() > 0f) { Text( - text = text.progressRead((comic.readingProgress * 100).toInt()), + text = text.progressRead((comic.displayReadingProgress() * 100).toInt()), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt index fcc58c5df..ba3483f69 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/home/ContinueViewModel.kt @@ -20,6 +20,7 @@ import io.leostrange.mrcomic.core.domain.analytics.calculateMascotProgress import io.leostrange.mrcomic.core.domain.analytics.resolveGamificationMetricsSnapshot import io.leostrange.mrcomic.core.domain.analytics.resolveMrComicMascotState import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.displayReadingProgress import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted @@ -68,9 +69,15 @@ internal enum class ContinueReturnSupportTone { internal fun resolveContinueStartupData( liveComics: List, liveTrail: List, - warmState: ContinueWarmState + warmState: ContinueWarmState, + liveDataReady: Boolean = false ): ContinueResolvedStartupData { return when { + liveDataReady -> ContinueResolvedStartupData( + comics = liveComics, + trail = liveTrail, + isLoading = false + ) liveComics.isNotEmpty() -> ContinueResolvedStartupData( comics = liveComics, trail = liveTrail, @@ -145,7 +152,8 @@ class ContinueViewModel @Inject constructor( val startupData = resolveContinueStartupData( liveComics = inputs.comics, liveTrail = inputs.trail, - warmState = warmState + warmState = warmState, + liveDataReady = true ) val comics = startupData.comics val trail = startupData.trail @@ -157,7 +165,7 @@ class ContinueViewModel @Inject constructor( .filterNot { it.isCompleted } .mapTo(linkedSetOf()) { it.id } val activeReading = comics - .filter { !it.isCompleted && it.readingProgress > 0f } + .filter { !it.isCompleted && it.displayReadingProgress() > 0f } .sortedByDescending { it.lastReadDate } val currentlyReading = activeReading.take(12) val mascotProgress = calculateMascotProgress(comics) @@ -210,7 +218,7 @@ class ContinueViewModel @Inject constructor( if (ws is ContinueWarmState.Ready) { val comics = ws.snapshot.comics val activeReading = comics - .filter { !it.isCompleted && it.readingProgress > 0f } + .filter { !it.isCompleted && it.displayReadingProgress() > 0f } .sortedByDescending { it.lastReadDate } ContinueUiState( isLoading = false, diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt index 4501dde4f..0704e8e73 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/AppNavigation.kt @@ -51,8 +51,8 @@ import io.leostrange.mrcomic.core.data.preferences.UserPreferences import io.leostrange.mrcomic.core.data.preferences.dataStore import io.leostrange.mrcomic.core.model.ReaderFormatCatalog import io.leostrange.mrcomic.core.model.storedReaderLocator -import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomNavigationBar -import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomNavigationItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomBar +import io.leostrange.mrcomic.core.ui.designsystem.MrComicBottomBarDestination import io.leostrange.mrcomic.core.ui.eink.LocalEInkMode import io.leostrange.mrcomic.core.ui.locale.LocalStrings import io.leostrange.mrcomic.feature.library.AudiobookPlayerScreen @@ -411,11 +411,25 @@ fun AppNavHost( onAudiobookClick = { audiobookId -> navigateToFullscreen(Screen.AudiobookPlayer.create(audiobookId)) }, - onQuoteClick = { comicId, page -> + onQuoteClick = { comicId, page, quote -> + // BUG-CANDIDATE-01: Use structured position from quote when available. + // The quote stores characterOffset and domAnchor for precise navigation; + // page is the legacy fallback when no structured position exists. + val quoteLocator = quote?.let { q -> + val pos = q.characterOffset?.takeIf { it > 0 } + val anchor = q.domAnchor?.takeIf { it.isNotBlank() } + if (pos != null || anchor != null) { + io.leostrange.mrcomic.core.model.ReaderLocator( + position = pos, + fragment = anchor + ) + } else null + } navigateToFullscreen( Screen.Reader.createForComic( comicId = comicId, - page = page + page = if (quoteLocator != null) null else page, + locator = quoteLocator ) ) }, @@ -431,9 +445,6 @@ fun AppNavHost( navController.navigate(Screen.ProgressProfile.route) { launchSingleTop = true } - }, - onOpdsCatalogClick = { - navController.navigate(Screen.OpdsCatalog.route) } ) } @@ -445,17 +456,6 @@ fun AppNavHost( ) } - composable(Screen.OpdsCatalog.route) { - val libraryVm: LibraryViewModel = hiltViewModel() - io.leostrange.mrcomic.feature.library.opds.OpdsCatalogScreen( - onNavigateBack = { navController.popBackStack() }, - onBookDownloaded = { file -> - libraryVm.addComicFromUri(android.net.Uri.fromFile(file)) - navController.popBackStack() - } - ) - } - composable(Screen.ProgressProfile.route) { val vm: LibraryViewModel = hiltViewModel() val scope = rememberCoroutineScope() @@ -654,14 +654,18 @@ private fun AppBottomBar( menuItems: List, onNavigate: (String) -> Unit ) { - MrComicBottomNavigationBar { - menuItems.forEach { item -> - MrComicBottomNavigationItem( - icon = item.icon, + MrComicBottomBar( + destinations = menuItems.map { item -> + MrComicBottomBarDestination( + route = item.route, label = item.label, - selected = currentRoute == item.route, - onClick = { onNavigate(item.destination) } + icon = item.icon, ) + }, + currentRoute = currentRoute, + onDestinationClick = { destination -> + val item = menuItems.firstOrNull { it.route == destination.route } + if (item != null) onNavigate(item.destination) } - } + ) } diff --git a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt index 75aa4821b..41b8d8214 100644 --- a/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt +++ b/android/app/src/main/java/io/leostrange/mrcomic/mrcomic/navigation/Screen.kt @@ -22,8 +22,6 @@ sealed class Screen(val route: String) { } } - data object OpdsCatalog : Screen("opds_catalog") - data object AudiobookPlayer : Screen("audiobook_player/{audiobookId}") { fun create(audiobookId: String) = "audiobook_player/$audiobookId" } diff --git a/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt b/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt index 15735cb0a..a80170002 100644 --- a/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt +++ b/android/app/src/test/java/io/leostrange/mrcomic/mrcomic/home/ContinueStartupWarmStateTest.kt @@ -64,6 +64,25 @@ class ContinueStartupWarmStateTest { assertEquals("live-1", resolved.trail.first().comicId) } + @Test + fun doesNotRestoreWarmSnapshotAfterLiveLibraryBecomesEmpty() { + val resolved = resolveContinueStartupData( + liveComics = emptyList(), + liveTrail = emptyList(), + warmState = ContinueWarmState.Ready( + ContinueWarmSnapshot( + comics = listOf(sampleComic("deleted")), + trail = listOf(sampleCheckpoint("deleted")) + ) + ), + liveDataReady = true + ) + + assertFalse(resolved.isLoading) + assertTrue(resolved.comics.isEmpty()) + assertTrue(resolved.trail.isEmpty()) + } + private fun sampleComic(id: String): Comic = Comic( id = id, title = "Sample", diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt index 7a12c5a65..0df4305e1 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabase.kt @@ -19,7 +19,7 @@ import io.leostrange.mrcomic.core.data.db.entity.TranslationCacheEntry TextHighlight::class, TranslationCacheEntry::class ], - version = 10, + version = 14, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt index 3b97e499d..4228477e5 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/AppDatabaseMigrations.kt @@ -4,6 +4,13 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase object AppDatabaseMigrations { + /** 13→14: Remove the retired online-catalog storage. */ + val MIGRATION_13_14 = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `opds_catalogs`") + } + } + /** * Columns of the `comics` table that were added to the [io.leostrange.mrcomic.core.model.Comic] * entity over time WITHOUT an accompanying `ALTER TABLE` migration (historically the only @@ -233,4 +240,82 @@ object AppDatabaseMigrations { ) } } + + /** 11→12: BUG-CANDIDATE-01 — Add structured position columns to saved_quotes. */ + val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + val existing = HashSet() + db.query("PRAGMA table_info(`saved_quotes`)").use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex >= 0) { + while (cursor.moveToNext()) { + existing.add(cursor.getString(nameIndex)) + } + } + } + if ("positionJson" !in existing) { + db.execSQL("ALTER TABLE `saved_quotes` ADD COLUMN `positionJson` TEXT") + } + if ("characterOffset" !in existing) { + db.execSQL("ALTER TABLE `saved_quotes` ADD COLUMN `characterOffset` INTEGER") + } + if ("domAnchor" !in existing) { + db.execSQL("ALTER TABLE `saved_quotes` ADD COLUMN `domAnchor` TEXT") + } + } + } + + /** 12→13: Add username and password columns to opds_catalogs for Basic auth. */ + val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + val existing = HashSet() + db.query("PRAGMA table_info(`opds_catalogs`)").use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex >= 0) { + while (cursor.moveToNext()) { + existing.add(cursor.getString(nameIndex)) + } + } + } + if ("username" !in existing) { + db.execSQL("ALTER TABLE `opds_catalogs` ADD COLUMN `username` TEXT NOT NULL DEFAULT ''") + } + if ("password" !in existing) { + db.execSQL("ALTER TABLE `opds_catalogs` ADD COLUMN `password` TEXT NOT NULL DEFAULT ''") + } + } + } + + /** 10→11: Add opds_catalogs table and seed default catalog sources. */ + val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `opds_catalogs` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` TEXT NOT NULL, + `url` TEXT NOT NULL, + `description` TEXT NOT NULL DEFAULT '', + `isSearchable` INTEGER NOT NULL DEFAULT 0, + `isDefault` INTEGER NOT NULL DEFAULT 0, + `sortOrder` INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + // Seed default catalogs + val defaults = listOf( + Triple("Project Gutenberg", "https://www.gutenberg.org/ebooks.opds/", "Free eBooks (public domain)"), + Triple("Feedbooks", "https://catalog.feedbooks.com/catalog/public_domain", "Public domain books"), + Triple("ManyBooks", "https://manybooks.net/opds", "Free eBooks collection"), + Triple("Internet Archive", "https://archive.org/advancedsearch.php?q=&fl[]=identifier&fl[]=title&fl[]=creator&sort[]=downloads+desc&mediatype=texts&output=opds", "Archive.org books & comics"), + Triple("Standard Ebooks", "https://standardebooks.org/opds", "Beautifully formatted free classics"), + ) + defaults.forEachIndexed { index, (name, url, desc) -> + db.execSQL( + "INSERT INTO opds_catalogs (name, url, description, isSearchable, isDefault, sortOrder) " + + "VALUES ('$name', '$url', '$desc', 1, 1, $index)" + ) + } + } + } } diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt index 06e3c5a5f..c45024353 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/ComicDao.kt @@ -49,7 +49,7 @@ interface ComicDao { SET currentPage = :currentPage, readingProgress = :progress, lastReadDate = :lastReadDate, - pageCount = CASE WHEN :pageCount > pageCount THEN :pageCount ELSE pageCount END, + pageCount = :pageCount, readerLocatorPosition = CASE WHEN :characterOffset IS NOT NULL THEN :characterOffset ELSE readerLocatorPosition diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt index 96c9e5429..8fd5502dd 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/db/entity/SavedQuote.kt @@ -24,5 +24,11 @@ data class SavedQuote( val targetLanguage: String? = null, val createdAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(), - val contentHash: String + val contentHash: String, + /** BUG-CANDIDATE-01: Structured position JSON for precise quote navigation. */ + val positionJson: String? = null, + /** BUG-CANDIDATE-01: Character offset for text-based relocation. */ + val characterOffset: Int? = null, + /** BUG-CANDIDATE-01: DOM anchor for fragment-based navigation. */ + val domAnchor: String? = null ) diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt index 6c9cbb71e..1caef7590 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/DatabaseModule.kt @@ -14,6 +14,8 @@ import javax.inject.Named import io.leostrange.mrcomic.core.data.db.QuoteDao import io.leostrange.mrcomic.core.data.db.TextHighlightDao import io.leostrange.mrcomic.core.data.db.TranslationCacheDao +import io.leostrange.mrcomic.core.data.preferences.UserPreferences +import io.leostrange.mrcomic.core.data.preferences.dataStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -38,7 +40,11 @@ object DatabaseModule { AppDatabaseMigrations.MIGRATION_6_7, AppDatabaseMigrations.MIGRATION_7_8, AppDatabaseMigrations.MIGRATION_8_9, - AppDatabaseMigrations.MIGRATION_9_10 + AppDatabaseMigrations.MIGRATION_9_10, + AppDatabaseMigrations.MIGRATION_10_11, + AppDatabaseMigrations.MIGRATION_11_12, + AppDatabaseMigrations.MIGRATION_12_13, + AppDatabaseMigrations.MIGRATION_13_14 ) // Never silently drop the user's library on a forward-migration gap (a missing // migration is a bug to fix, not data to wipe). Destructive recovery is kept only for @@ -50,6 +56,12 @@ object DatabaseModule { @Singleton fun provideQuoteDao(appDatabase: AppDatabase): QuoteDao = appDatabase.quoteDao() + /** Shared preferences facade over the app DataStore. */ + @Provides + @Singleton + fun provideUserPreferences(@ApplicationContext context: Context): UserPreferences = + UserPreferences(context.dataStore) + @Provides @Singleton fun provideAudiobookDao(db: AppDatabase): AudiobookDao = db.audiobookDao() @@ -81,4 +93,5 @@ object DatabaseModule { @Provides @Singleton fun provideTranslationCacheDao(db: AppDatabase): TranslationCacheDao = db.translationCacheDao() + } diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/OpdsModule.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/OpdsModule.kt deleted file mode 100644 index 9dd68fe00..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/di/OpdsModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.leostrange.mrcomic.core.data.di - -import io.leostrange.mrcomic.core.data.opds.OpdsNetworkClient -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object OpdsModule { - - @Provides - @Singleton - fun provideOpdsNetworkClient(): OpdsNetworkClient = OpdsNetworkClient() -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsFeedParser.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsFeedParser.kt deleted file mode 100644 index 08fee835b..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsFeedParser.kt +++ /dev/null @@ -1,159 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import android.util.Xml -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import io.leostrange.mrcomic.core.model.OpdsLink -import org.xmlpull.v1.XmlPullParser -import java.io.InputStream - -/** - * Parses OPDS feeds in Atom/XML format. - * - * OPDS feeds are Atom documents with additional link relations - * defined by the OPDS specification (acquisition, navigation, search). - */ -internal object OpdsFeedParser { - - private const val ATOM_NS = "http://www.w3.org/2005/Atom" - - fun parse(input: InputStream): OpdsFeed { - val parser = Xml.newPullParser().apply { - setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) - setInput(input, null) - } - - var title = "" - val entries = mutableListOf() - val links = mutableListOf() - var nextLink: String? = null - var searchLink: String? = null - - // Navigate to - while (parser.eventType != XmlPullParser.END_DOCUMENT) { - if (parser.eventType == XmlPullParser.START_TAG && - parser.namespace == ATOM_NS && parser.name == "feed" - ) { - break - } - parser.next() - } - - // Parse feed children - while (parser.eventType != XmlPullParser.END_DOCUMENT) { - if (parser.eventType == XmlPullParser.END_TAG && - parser.namespace == ATOM_NS && parser.name == "feed" - ) { - break - } - if (parser.eventType == XmlPullParser.START_TAG) { - when { - parser.namespace == ATOM_NS && parser.name == "title" -> - title = readText(parser, "title") - parser.namespace == ATOM_NS && parser.name == "link" -> { - val link = readLink(parser) - if (link != null) { - links.add(link) - if (link.isNext) nextLink = link.href - if (link.isSearch) searchLink = link.href - } - } - parser.namespace == ATOM_NS && parser.name == "entry" -> - readEntry(parser)?.let { entries.add(it) } - } - } - parser.next() - } - - return OpdsFeed( - title = title.ifBlank { "Untitled" }, - entries = entries, - links = links, - nextLink = nextLink, - searchLink = searchLink - ) - } - - private fun readEntry(parser: XmlPullParser): OpdsEntry? { - var title = "" - var author: String? = null - var summary: String? = null - var thumbnailUrl: String? = null - var updated: String? = null - val links = mutableListOf() - - val depth = parser.depth - parser.next() - while (!(parser.eventType == XmlPullParser.END_TAG && parser.depth == depth)) { - if (parser.eventType == XmlPullParser.START_TAG) { - when { - parser.namespace == ATOM_NS && parser.name == "title" -> - title = readText(parser, "title") - parser.namespace == ATOM_NS && parser.name == "author" -> - author = readAuthorName(parser) - parser.namespace == ATOM_NS && parser.name == "summary" -> - summary = readText(parser, "summary") - parser.namespace == ATOM_NS && parser.name == "content" -> - if (summary.isNullOrBlank()) summary = readText(parser, "content") - parser.namespace == ATOM_NS && parser.name == "updated" -> - updated = readText(parser, "updated") - parser.namespace == ATOM_NS && parser.name == "link" -> { - val link = readLink(parser) - if (link != null) { - links.add(link) - if (link.isThumbnail && thumbnailUrl == null) { - thumbnailUrl = link.href - } - } - } - } - } - parser.next() - } - - return OpdsEntry( - title = title.ifBlank { return null }, - author = author, - summary = summary, - thumbnailUrl = thumbnailUrl, - updated = updated, - links = links - ) - } - - private fun readLink(parser: XmlPullParser): OpdsLink? { - val href = parser.getAttributeValue(null, "href") ?: return null - val rel = parser.getAttributeValue(null, "rel") ?: "alternate" - val type = parser.getAttributeValue(null, "type") - val title = parser.getAttributeValue(null, "title") - return OpdsLink(href = href, rel = rel, type = type, title = title) - } - - private fun readAuthorName(parser: XmlPullParser): String? { - val depth = parser.depth - parser.next() - var name: String? = null - while (!(parser.eventType == XmlPullParser.END_TAG && parser.depth == depth)) { - if (parser.eventType == XmlPullParser.START_TAG && - parser.namespace == ATOM_NS && parser.name == "name" - ) { - name = readText(parser, "name") - } - parser.next() - } - return name - } - - private fun readText(parser: XmlPullParser, tag: String): String { - val depth = parser.depth - val sb = StringBuilder() - parser.next() - while (!(parser.eventType == XmlPullParser.END_TAG && parser.depth == depth)) { - if (parser.eventType == XmlPullParser.TEXT || parser.eventType == XmlPullParser.CDSECT) { - sb.append(parser.text) - } - parser.next() - } - return sb.toString().trim() - } -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkClient.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkClient.kt deleted file mode 100644 index a310c8267..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkClient.kt +++ /dev/null @@ -1,140 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import android.util.Log -import io.leostrange.mrcomic.core.model.OpdsFeed -import java.io.File -import java.io.IOException -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request - -/** - * Network client for OPDS catalog operations. - * Fetches Atom/XML feeds and downloads book files. - */ -class OpdsNetworkClient( - private val client: OkHttpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .followRedirects(true) - .build() -) { - - companion object { - private const val TAG = "OpdsNetworkClient" - private const val USER_AGENT = "MrComic/2.1 (Android; OPDS)" - private const val MAX_DOWNLOAD_ATTEMPTS = 3 - private const val DOWNLOAD_RETRY_DELAY_MILLIS = 500L - } - - /** Fetch an OPDS feed from the given URL. */ - suspend fun fetchFeed(url: String): OpdsFeed = withContext(Dispatchers.IO) { - Log.d(TAG, "Fetching OPDS feed: $url") - val request = Request.Builder() - .url(url) - .header("Accept", "application/atom+xml, application/xml, text/xml, */*") - .header("User-Agent", USER_AGENT) - .get() - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw IOException("OPDS feed request failed: ${response.code} ${response.message}") - } - val body = response.body ?: throw IOException("Empty response body") - body.byteStream().use { stream -> - OpdsFeedParser.parse(stream) - } - } - } - - /** Download a book file from the given URL to the specified output file. */ - suspend fun downloadBook( - url: String, - outputFile: File, - onProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? = null - ): File = withContext(Dispatchers.IO) { - outputFile.parentFile?.mkdirs() - val tempFile = File(outputFile.parentFile, ".${outputFile.name}.part") - var lastError: IOException? = null - - repeat(MAX_DOWNLOAD_ATTEMPTS) { attempt -> - try { - downloadOnce(url, tempFile, onProgress) - if (outputFile.exists()) outputFile.delete() - if (!tempFile.renameTo(outputFile)) { - tempFile.copyTo(outputFile, overwrite = true) - tempFile.delete() - } - Log.d(TAG, "Download complete: ${outputFile.length()} bytes") - return@withContext outputFile - } catch (error: IOException) { - tempFile.delete() - lastError = error - val hasAttemptsLeft = attempt + 1 < MAX_DOWNLOAD_ATTEMPTS - if (!hasAttemptsLeft || error is OpdsHttpException && !error.retryable) { - throw error - } - Log.w(TAG, "Download attempt ${attempt + 1} failed; retrying", error) - delay(DOWNLOAD_RETRY_DELAY_MILLIS * (attempt + 1)) - } - } - - throw lastError ?: IOException("Download failed without an error") - } - - private fun downloadOnce( - url: String, - outputFile: File, - onProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? - ) { - Log.d(TAG, "Downloading book: $url -> ${outputFile.absolutePath}") - val request = Request.Builder() - .url(url) - .header("User-Agent", USER_AGENT) - .get() - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw OpdsHttpException( - code = response.code, - message = "Download failed: ${response.code} ${response.message}", - retryable = response.code == 408 || response.code == 429 || response.code >= 500 - ) - } - val body = response.body ?: throw IOException("Empty response body") - val totalBytes = body.contentLength() - onProgress?.invoke(0L, totalBytes) - - body.byteStream().use { input -> - outputFile.outputStream().use { output -> - val buffer = ByteArray(8192) - var bytesRead = 0L - var lastProgressReport = 0L - while (true) { - val read = input.read(buffer) - if (read == -1) break - output.write(buffer, 0, read) - bytesRead += read - // Report progress at most every 64KB. - if (bytesRead - lastProgressReport >= 65536) { - onProgress?.invoke(bytesRead, totalBytes) - lastProgressReport = bytesRead - } - } - onProgress?.invoke(bytesRead, totalBytes) - } - } - } - } - - private class OpdsHttpException( - val code: Int, - message: String, - val retryable: Boolean - ) : IOException(message) -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsRepository.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsRepository.kt deleted file mode 100644 index 01a1bb903..000000000 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/opds/OpdsRepository.kt +++ /dev/null @@ -1,157 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import android.content.Context -import android.util.Log -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Repository for OPDS catalog browsing and book downloading. - * Manages catalog sources, feed navigation, and book import. - */ -@Singleton -class OpdsRepository @Inject constructor( - @ApplicationContext private val context: Context, - private val networkClient: OpdsNetworkClient -) { - - companion object { - private const val TAG = "OpdsRepository" - private val DOWNLOADS_DIR = "opds_downloads" - } - - /** Pre-configured OPDS catalog sources. */ - val defaultCatalogs = listOf( - OpdsCatalogSource( - name = "Project Gutenberg", - url = "https://www.gutenberg.org/ebooks.opds/", - description = "Free eBooks (public domain)", - isSearchable = true - ), - OpdsCatalogSource( - name = "Feedbooks", - url = "https://catalog.feedbooks.com/catalog/public_domain", - description = "Public domain books" - ), - OpdsCatalogSource( - name = "ManyBooks", - url = "https://manybooks.net/opds", - description = "Free eBooks collection" - ) - ) - - /** Fetch an OPDS feed from the given URL. */ - suspend fun browse(url: String): OpdsFeed = withContext(Dispatchers.IO) { - Log.d(TAG, "Browsing: $url") - networkClient.fetchFeed(url) - } - - /** Search within an OPDS catalog. */ - suspend fun search(searchUrl: String, query: String): OpdsFeed = withContext(Dispatchers.IO) { - // OPDS search uses OpenSearch URL template: replace {searchTerms} or {?searchTerms} - val url = buildOpdsSearchUrl(searchUrl, query) - Log.d(TAG, "Searching: $url") - networkClient.fetchFeed(url) - } - - /** Download a book from an OPDS entry to local storage. */ - suspend fun downloadBook( - entry: OpdsEntry, - onProgress: ((Long, Long) -> Unit)? = null - ): File = withContext(Dispatchers.IO) { - val acquisitionLink = entry.acquisitionLink - ?: throw IllegalArgumentException("Entry has no acquisition link: ${entry.title}") - - val extension = guessExtension(acquisitionLink.type, acquisitionLink.href) - val safeName = entry.title.replace(Regex("[^a-zA-Z0-9а-яА-ЯёЁ\\-_. ]"), "_") - .take(80) - .ifBlank { "book" } - // Include the acquisition identity so equal titles cannot race on one file. - val downloadIdentity = Integer.toHexString(acquisitionLink.href.hashCode()) - val fileName = "${safeName}-${downloadIdentity}.$extension" - - val downloadsDir = File(context.filesDir, DOWNLOADS_DIR).apply { mkdirs() } - val outputFile = uniqueDownloadFile(downloadsDir, fileName) - - Log.d(TAG, "Downloading '${entry.title}' from ${acquisitionLink.href}") - networkClient.downloadBook(acquisitionLink.href, outputFile, onProgress) - } - - /** Get the local downloads directory. */ - fun getDownloadsDir(): File = File(context.filesDir, DOWNLOADS_DIR).apply { mkdirs() } - - /** Clean up downloaded files. */ - fun cleanupDownloads() { - val dir = File(context.filesDir, DOWNLOADS_DIR) - if (dir.exists()) { - dir.listFiles()?.forEach { it.delete() } - } - } - - private fun uniqueDownloadFile(downloadsDir: File, fileName: String): File { - val candidate = File(downloadsDir, fileName) - if (!candidate.exists()) return candidate - - val baseName = fileName.substringBeforeLast('.', fileName) - val extension = fileName.substringAfterLast('.', "") - var index = 2 - while (true) { - val suffix = " ($index)" + if (extension.isBlank()) "" else ".${extension}" - val next = File(downloadsDir, baseName + suffix) - if (!next.exists()) return next - index++ - } - } - - private fun guessExtension(mimeType: String?, href: String): String { - // Try MIME type first - val fromMime = when (mimeType) { - "application/epub+zip" -> "epub" - "application/x-mobipocket-ebook" -> "mobi" - "application/pdf" -> "pdf" - "application/x-fictionbook+xml" -> "fb2" - "application/rtf", "text/rtf" -> "rtf" - "text/plain" -> "txt" - "text/html" -> "html" - "application/zip" -> "zip" - else -> null - } - if (fromMime != null) return fromMime - - // Try URL extension - val urlPath = href.substringBefore("?").substringBefore("#") - val lastDot = urlPath.lastIndexOf('.') - if (lastDot >= 0) { - val ext = urlPath.substring(lastDot + 1).lowercase() - if (ext.length in 2..5) return ext - } - - return "epub" // default fallback - } -} - -/** Build a correctly encoded URL from an OPDS/OpenSearch query template. */ -internal fun buildOpdsSearchUrl(searchUrl: String, query: String): String { - val encoded = java.net.URLEncoder.encode(query, "UTF-8") - .replace("+", "%20") - - return when { - "{?searchTerms}" in searchUrl -> searchUrl.replace("{?searchTerms}", "?q=$encoded") - "{searchTerms}" in searchUrl -> searchUrl.replace("{searchTerms}", encoded) - else -> { - val separator = when { - searchUrl.endsWith("?") || searchUrl.endsWith("&") -> "" - "?" in searchUrl -> "&" - else -> "?" - } - "$searchUrl${separator}q=$encoded" - } - } -} diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt index 0afa9e3f3..f9c575280 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/preferences/PreferencesKeys.kt @@ -27,6 +27,16 @@ object PreferencesKeys { val READER_IMAGE_SCALE_MODE = stringPreferencesKey("reader_image_scale_mode") val READER_PAGE_MARGIN_CROP_HORIZONTAL = floatPreferencesKey("reader_page_margin_crop_horizontal") val READER_PAGE_MARGIN_CROP_VERTICAL = floatPreferencesKey("reader_page_margin_crop_vertical") + // Обрезка пустых полей документа: посекционные значения (0..0.22) + флаги. + // Посекционные ключи — источник истины; H/V ключи остаются для миграции + // старых симметричных настроек (используются только при первом чтении). + val READER_PAGE_MARGIN_CROP_ENABLED = booleanPreferencesKey("reader_page_margin_crop_enabled") + val READER_PAGE_MARGIN_CROP_LEFT = floatPreferencesKey("reader_page_margin_crop_left") + val READER_PAGE_MARGIN_CROP_TOP = floatPreferencesKey("reader_page_margin_crop_top") + val READER_PAGE_MARGIN_CROP_RIGHT = floatPreferencesKey("reader_page_margin_crop_right") + val READER_PAGE_MARGIN_CROP_BOTTOM = floatPreferencesKey("reader_page_margin_crop_bottom") + val READER_PAGE_MARGIN_CROP_SYMMETRIC = booleanPreferencesKey("reader_page_margin_crop_symmetric") + val READER_PAGE_MARGIN_CROP_SHOW_WARNING = booleanPreferencesKey("reader_page_margin_crop_show_warning") val APP_NAV_TRANSITION_STYLE = stringPreferencesKey("app_nav_transition_style") // NONE/FADE/SLIDE/LIFT val READER_PAGE_ANIMATION = stringPreferencesKey("reader_page_animation") // NONE/SLIDE/FADE val READER_PAGE_SOUND = booleanPreferencesKey("reader_page_sound") // page-flip sound @@ -187,6 +197,7 @@ object PreferencesKeys { val READER_CHROME_SHOW_TRANSLATE = booleanPreferencesKey("reader_chrome_show_translate") val READER_CHROME_SHOW_BRIGHTNESS = booleanPreferencesKey("reader_chrome_show_brightness") val READER_CHROME_SHOW_AUTO_SCROLL = booleanPreferencesKey("reader_chrome_show_auto_scroll") + val READER_CHROME_SHOW_CROP = booleanPreferencesKey("reader_chrome_show_crop") // Последняя секция библиотеки (FILES/AUDIOBOOKS/BOOKMARKS/QUOTES/ACHIEVEMENTS) val LIBRARY_CONTENT_SECTION = stringPreferencesKey("library_content_section") diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt index ec5f78d88..8860f76a6 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicBackupMerge.kt @@ -2,6 +2,7 @@ package io.leostrange.mrcomic.core.data.repository import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.model.readingProgressForPage import java.io.File @@ -79,7 +80,7 @@ import java.io.File lastModified = maxOf(existing.lastModified, backup.lastModified), folderId = existing.folderId ?: backup.folderId, lastReadDate = maxOf(existing.lastReadDate ?: 0L, backup.lastReadDate ?: 0L).takeIf { it > 0L }, - readingProgress = maxOf(existing.readingProgress, mergedProgress), + readingProgress = maxOf(existing.displayReadingProgress(), mergedProgress), currentPage = maxOf(existing.currentPage, mergedCurrentPage), isBookmarked = existing.isBookmarked || backup.isBookmarked, tags = existing.tags.ifBlank { backup.tags }, diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt index 29186ce5c..bbe52d398 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/ComicFormatDetector.kt @@ -54,7 +54,7 @@ internal class ComicFormatDetector( ) { "cbz" -> ComicFormat.CBZ "zip" -> ComicFormat.ZIP - "cbr" -> ComicFormat.RAR + "cbr" -> ComicFormat.CBR "rar" -> ComicFormat.RAR "cb7", "7z" -> ComicFormat.SEVENZ "cbt", "tar" -> ComicFormat.TAR diff --git a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt index 9af03fa5f..7ee8a606a 100644 --- a/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt +++ b/android/core-data/src/main/java/io/leostrange/mrcomic/core/data/repository/QuoteRepository.kt @@ -31,7 +31,11 @@ class QuoteRepository @Inject constructor( text: String, translatedText: String? = null, sourceLanguage: String? = null, - targetLanguage: String? = null + targetLanguage: String? = null, + /** BUG-CANDIDATE-01: Structured position for precise quote navigation. */ + positionJson: String? = null, + characterOffset: Int? = null, + domAnchor: String? = null ): SaveQuoteResult? { val normalizedText = normalizeQuoteText(text) if (normalizedText.isBlank()) return null @@ -49,7 +53,11 @@ class QuoteRepository @Inject constructor( translatedText = normalizedTranslation ?: existing.translatedText, sourceLanguage = sourceLanguage ?: existing.sourceLanguage, targetLanguage = targetLanguage ?: existing.targetLanguage, - updatedAt = System.currentTimeMillis() + updatedAt = System.currentTimeMillis(), + // BUG-CANDIDATE-01: Update position if provided + positionJson = positionJson ?: existing.positionJson, + characterOffset = characterOffset ?: existing.characterOffset, + domAnchor = domAnchor ?: existing.domAnchor ) quoteDao.updateQuote(merged) return SaveQuoteResult(merged, inserted = false) @@ -64,7 +72,10 @@ class QuoteRepository @Inject constructor( translatedText = normalizedTranslation, sourceLanguage = sourceLanguage, targetLanguage = targetLanguage, - contentHash = contentHash + contentHash = contentHash, + positionJson = positionJson, + characterOffset = characterOffset, + domAnchor = domAnchor ) quoteDao.insertQuote(quote) return SaveQuoteResult(quote, inserted = true) diff --git a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkIntegrationTest.kt b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkIntegrationTest.kt deleted file mode 100644 index 213bde8a9..000000000 --- a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsNetworkIntegrationTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsLink -import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) -class OpdsNetworkIntegrationTest { - - private lateinit var server: MockWebServer - private lateinit var baseUrl: String - private lateinit var repository: OpdsRepository - - @Before - fun setUp() { - server = MockWebServer() - server.start() - baseUrl = server.url("/").toString().removeSuffix("/") - repository = OpdsRepository( - context = RuntimeEnvironment.getApplication(), - networkClient = OpdsNetworkClient(OkHttpClient()) - ) - repository.cleanupDownloads() - } - - @After - fun tearDown() { - repository.cleanupDownloads() - server.shutdown() - } - - @Test - fun browseSearchAndDownloadWorkAgainstLocalOpdsServer() = kotlinx.coroutines.test.runTest { - server.enqueue(atomResponse(catalogFeed())) - val catalog = repository.browse("$baseUrl/catalog") - assertEquals("Local OPDS Catalog", catalog.title) - assertTrue(catalog.searchLink!!.contains("{searchTerms}")) - server.takeRequest() - - server.enqueue(atomResponse(searchFeed())) - val searchResult = repository.search(catalog.searchLink!!, "hello world & peace") - val searchRequest = server.takeRequest() - assertEquals("request=${searchRequest.requestUrl}", "/search", searchRequest.requestUrl!!.encodedPath) - assertEquals("request=${searchRequest.requestUrl}", "hello world & peace", searchRequest.requestUrl!!.queryParameter("q")) - assertTrue(searchRequest.requestUrl!!.encodedQuery!!.contains("%26")) - assertFalse(searchRequest.requestUrl!!.encodedQuery!!.contains(" ")) - - assertEquals("Search results", searchResult.title) - val book = searchResult.entries.single() - assertTrue(book.isBook) - assertEquals("Integration Book", book.title) - - server.enqueue( - MockResponse() - .setHeader("Content-Type", "application/epub+zip") - .setBody("integration-book-content") - ) - val downloadedFile = repository.downloadBook(book) - server.takeRequest() - assertEquals("integration-book-content", downloadedFile.readText()) - assertTrue(downloadedFile.exists()) - } - - @Test - fun downloadRetriesTransientServerFailureAndCleansTemporaryFile() = kotlinx.coroutines.test.runTest { - val book = OpdsEntry( - title = "Flaky Book", - links = listOf( - OpdsLink( - href = "$baseUrl/flaky-book", - rel = "http://opds-spec.org/acquisition/open-access", - type = "application/epub+zip" - ) - ) - ) - server.enqueue(MockResponse().setResponseCode(503).setBody("temporary failure")) - server.enqueue( - MockResponse() - .setHeader("Content-Type", "application/epub+zip") - .setBody("recovered-book-content") - ) - - val downloadedFile = repository.downloadBook(book) - - assertEquals("/flaky-book", server.takeRequest().path) - assertEquals("/flaky-book", server.takeRequest().path) - assertEquals("recovered-book-content", downloadedFile.readText()) - assertTrue(repository.getDownloadsDir().listFiles().orEmpty().none { it.name.endsWith(".part") }) - } - - private fun catalogFeed(): String = """ - - - Local OPDS Catalog - - - """.trimIndent() - - private fun searchFeed(): String = """ - - - Search results - - Integration Book - - - - """.trimIndent() - - private fun atomResponse(body: String) = MockResponse() - .setHeader("Content-Type", "application/atom+xml") - .setBody(body) -} diff --git a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsRepositoryTest.kt b/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsRepositoryTest.kt deleted file mode 100644 index 4b9b73d38..000000000 --- a/android/core-data/src/test/java/io/leostrange/mrcomic/core/data/opds/OpdsRepositoryTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.leostrange.mrcomic.core.data.opds - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class OpdsRepositoryTest { - - @Test - fun searchTermsTemplatePercentEncodesSpacesUnicodeAndAmpersands() { - val url = buildOpdsSearchUrl( - "https://example.test/search?q={searchTerms}", - "война и мир & peace" - ) - - assertEquals( - "https://example.test/search?q=%D0%B2%D0%BE%D0%B9%D0%BD%D0%B0%20%D0%B8%20%D0%BC%D0%B8%D1%80%20%26%20peace", - url - ) - assertFalse(url.contains("+")) - } - - @Test - fun optionalSearchTermsTemplateAddsQueryParameter() { - val url = buildOpdsSearchUrl( - "https://example.test/search{?searchTerms}", - "hello world" - ) - - assertEquals("https://example.test/search?q=hello%20world", url) - } - - @Test - fun searchUrlWithoutTemplatePreservesExistingQueryParameters() { - val url = buildOpdsSearchUrl( - "https://example.test/search?lang=en", - "a&b" - ) - - assertEquals("https://example.test/search?lang=en&q=a%26b", url) - assertTrue(url.contains("lang=en")) - } -} diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/AchievementTracker.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/AchievementTracker.kt new file mode 100644 index 000000000..ed23208e1 --- /dev/null +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/AchievementTracker.kt @@ -0,0 +1,249 @@ +package io.leostrange.mrcomic.core.domain.analytics + +import io.leostrange.mrcomic.core.model.Achievement +import io.leostrange.mrcomic.core.model.AchievementDefinitions +import io.leostrange.mrcomic.core.model.AchievementNotification +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.AchievementRequirement +import io.leostrange.mrcomic.core.model.AchievementStatus +import io.leostrange.mrcomic.core.model.MascotStage +import io.leostrange.mrcomic.core.model.UserAchievements +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Данные о прогрессе пользователя для проверки достижений + */ +data class UserProgressData( + val pagesRead: Int = 0, + val titlesCompleted: Int = 0, + val streakDays: Int = 0, + val readingTimeMinutes: Int = 0, + val singleSessionPages: Int = 0, + val weeklyGoalCompleted: Int = 0, + val mascotStage: MascotStage = MascotStage.CHILD, + val totalXp: Int = 0 +) + +/** + * Трекер достижений + */ +@Singleton +class AchievementTracker @Inject constructor() { + + private val _userProgress = MutableStateFlow(UserProgressData()) + private val _unlockedAchievements = MutableStateFlow>(emptyMap()) + private val _notifications = MutableStateFlow>(emptyList()) + + /** + * Текущий прогресс пользователя + */ + val userProgress: StateFlow = _userProgress.asStateFlow() + + /** + * Разблокированные достижения (achievementId -> timestamp) + */ + val unlockedAchievements: Flow> = _unlockedAchievements.asStateFlow() + + /** + * Уведомления о новых достижениях + */ + val notifications: Flow> = _notifications.asStateFlow() + + /** + * Прогресс всех достижений + */ + val achievementProgress: Flow> = combine( + _userProgress, + _unlockedAchievements + ) { progress, unlocked -> + AchievementDefinitions.allAchievements.map { achievement -> + val isUnlocked = unlocked.containsKey(achievement.id) + val currentProgress = calculateProgress(achievement, progress) + AchievementProgress( + achievementId = achievement.id, + status = when { + isUnlocked -> AchievementStatus.UNLOCKED + currentProgress >= 1f -> AchievementStatus.UNLOCKED + else -> AchievementStatus.LOCKED + }, + currentProgress = currentProgress, + unlockedAt = unlocked[achievement.id] + ) + } + } + + /** + * Общая статистика достижений + */ + val userAchievements: Flow = combine( + achievementProgress, + _unlockedAchievements + ) { progressList, unlocked -> + val unlockedCount = progressList.count { it.status == AchievementStatus.UNLOCKED } + val totalXp = unlocked.keys.sumOf { id -> + AchievementDefinitions.getById(id)?.xpReward ?: 0 + } + UserAchievements( + achievements = progressList, + totalXpEarned = totalXp, + unlockedCount = unlockedCount, + totalCount = AchievementDefinitions.allAchievements.size + ) + } + + /** + * Обновить прогресс пользователя + */ + fun updateProgress(progress: UserProgressData) { + _userProgress.value = progress + checkForNewAchievements(progress) + } + + /** + * Обновить конкретную метрику + */ + fun updatePagesRead(pages: Int) { + _userProgress.value = _userProgress.value.copy(pagesRead = pages) + checkForNewAchievements(_userProgress.value) + } + + fun updateTitlesCompleted(titles: Int) { + _userProgress.value = _userProgress.value.copy(titlesCompleted = titles) + checkForNewAchievements(_userProgress.value) + } + + fun updateStreakDays(days: Int) { + _userProgress.value = _userProgress.value.copy(streakDays = days) + checkForNewAchievements(_userProgress.value) + } + + fun updateReadingTime(minutes: Int) { + _userProgress.value = _userProgress.value.copy(readingTimeMinutes = minutes) + checkForNewAchievements(_userProgress.value) + } + + fun updateSingleSessionPages(pages: Int) { + _userProgress.value = _userProgress.value.copy(singleSessionPages = pages) + checkForNewAchievements(_userProgress.value) + } + + fun updateWeeklyGoalCompleted(count: Int) { + _userProgress.value = _userProgress.value.copy(weeklyGoalCompleted = count) + checkForNewAchievements(_userProgress.value) + } + + fun updateMascotStage(stage: MascotStage) { + _userProgress.value = _userProgress.value.copy(mascotStage = stage) + checkForNewAchievements(_userProgress.value) + } + + fun updateTotalXp(xp: Int) { + _userProgress.value = _userProgress.value.copy(totalXp = xp) + checkForNewAchievements(_userProgress.value) + } + + /** + * Проверить и разблокировать новые достижения + */ + private fun checkForNewAchievements(progress: UserProgressData) { + val currentUnlocked = _unlockedAchievements.value.toMutableMap() + val newNotifications = mutableListOf() + + AchievementDefinitions.allAchievements.forEach { achievement -> + if (!currentUnlocked.containsKey(achievement.id)) { + val isUnlocked = checkRequirement(achievement.requirement, progress) + if (isUnlocked) { + currentUnlocked[achievement.id] = System.currentTimeMillis() + newNotifications.add( + AchievementNotification( + achievement = achievement, + xpEarned = achievement.xpReward, + timestamp = System.currentTimeMillis() + ) + ) + } + } + } + + if (newNotifications.isNotEmpty()) { + _unlockedAchievements.value = currentUnlocked + _notifications.value = _notifications.value + newNotifications + } + } + + /** + * Проверить выполнение требования + */ + private fun checkRequirement( + requirement: AchievementRequirement, + progress: UserProgressData + ): Boolean = when (requirement) { + is AchievementRequirement.PagesRead -> progress.pagesRead >= requirement.pages + is AchievementRequirement.TitlesCompleted -> progress.titlesCompleted >= requirement.titles + is AchievementRequirement.StreakDays -> progress.streakDays >= requirement.days + is AchievementRequirement.ReadingTimeMinutes -> progress.readingTimeMinutes >= requirement.minutes + is AchievementRequirement.GenresExplored -> false // TODO: Implement genre tracking + is AchievementRequirement.SingleSessionPages -> progress.singleSessionPages >= requirement.pages + is AchievementRequirement.WeeklyGoalCompleted -> progress.weeklyGoalCompleted >= requirement.weeks + is AchievementRequirement.MascotStage -> progress.mascotStage.ordinal >= requirement.stage.ordinal + is AchievementRequirement.TotalXp -> progress.totalXp >= requirement.xp + is AchievementRequirement.And -> requirement.requirements.all { checkRequirement(it, progress) } + is AchievementRequirement.Or -> requirement.requirements.any { checkRequirement(it, progress) } + } + + /** + * Рассчитать прогресс достижения (0.0 - 1.0) + */ + private fun calculateProgress( + achievement: Achievement, + progress: UserProgressData + ): Float { + if (_unlockedAchievements.value.containsKey(achievement.id)) return 1f + + return when (val req = achievement.requirement) { + is AchievementRequirement.PagesRead -> + (progress.pagesRead.toFloat() / req.pages).coerceIn(0f, 1f) + is AchievementRequirement.TitlesCompleted -> + (progress.titlesCompleted.toFloat() / req.titles).coerceIn(0f, 1f) + is AchievementRequirement.StreakDays -> + (progress.streakDays.toFloat() / req.days).coerceIn(0f, 1f) + is AchievementRequirement.ReadingTimeMinutes -> + (progress.readingTimeMinutes.toFloat() / req.minutes).coerceIn(0f, 1f) + is AchievementRequirement.SingleSessionPages -> + (progress.singleSessionPages.toFloat() / req.pages).coerceIn(0f, 1f) + is AchievementRequirement.WeeklyGoalCompleted -> + (progress.weeklyGoalCompleted.toFloat() / req.weeks).coerceIn(0f, 1f) + is AchievementRequirement.MascotStage -> + if (progress.mascotStage.ordinal >= req.stage.ordinal) 1f else 0f + is AchievementRequirement.TotalXp -> + (progress.totalXp.toFloat() / req.xp).coerceIn(0f, 1f) + is AchievementRequirement.And -> + req.requirements.minOf { calculateProgress(achievement.copy(requirement = it), progress) } + is AchievementRequirement.Or -> + req.requirements.maxOf { calculateProgress(achievement.copy(requirement = it), progress) } + is AchievementRequirement.GenresExplored -> 0f // TODO: Implement genre tracking + } + } + + /** + * Очистить уведомления + */ + fun clearNotifications() { + _notifications.value = emptyList() + } + + /** + * Отметить достижение как полученное + */ + fun claimAchievement(achievementId: String) { + // В текущей реализации достижения автоматически считаются полученными + // Можно расширить для подтверждения получения награды + } +} diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/GamificationIntegration.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/GamificationIntegration.kt new file mode 100644 index 000000000..bf73ecb58 --- /dev/null +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/GamificationIntegration.kt @@ -0,0 +1,131 @@ +package io.leostrange.mrcomic.core.domain.analytics + +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState +import io.leostrange.mrcomic.core.model.Comic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Интеграция системы достижений с существующими системами геймификации + */ +@Singleton +class GamificationIntegration @Inject constructor( + private val achievementTracker: AchievementTracker, + private val mascotProgressCalculator: MascotProgressCalculator, + private val dailyReadingGoalStore: DailyReadingGoalStore +) { + /** + * Инициализировать интеграцию + */ + fun initialize(scope: CoroutineScope) { + // Подписываемся на изменения в ежедневных целях + scope.launch { + dailyReadingGoalStore.goalState.collect { goalState -> + updateFromGoalState(goalState) + } + } + } + + /** + * Обновить данные о достижениях на основе состояния ежедневных целей + */ + private fun updateFromGoalState(goalState: DailyReadingGoalState) { + // Обновляем прогресс страниц + achievementTracker.updatePagesRead(goalState.pagesReadToday) + + // Обновляем серии + achievementTracker.updateStreakDays(goalState.currentStreak) + + // Обновляем недельные цели + if (goalState.isWeeklyPlanCompleted) { + achievementTracker.updateWeeklyGoalCompleted( + achievementTracker.userProgress.value.weeklyGoalCompleted + 1 + ) + } + } + + /** + * Обновить данные о достижениях на основе прогресса маскота + */ + fun updateFromMascotProgress(progress: MascotProgressState) { + achievementTracker.updateMascotStage(progress.stage) + achievementTracker.updateTotalXp(progress.xp) + achievementTracker.updatePagesRead(progress.approxPagesRead) + achievementTracker.updateTitlesCompleted(progress.completedTitles) + } + + /** + * Обновить данные о достижениях на основе списка комиксов + */ + fun updateFromComics(comics: List) { + val completedTitles = comics.count { it.isCompleted } + achievementTracker.updateTitlesCompleted(completedTitles) + } + + /** + * Записать время чтения + */ + fun recordReadingTime(durationMillis: Long) { + val minutes = (durationMillis / 60_000).toInt() + if (minutes > 0) { + achievementTracker.updateReadingTime( + achievementTracker.userProgress.value.readingTimeMinutes + minutes + ) + } + } + + /** + * Записать чтение за одну сессию + */ + fun recordSingleSessionReading(pages: Int) { + val currentMax = achievementTracker.userProgress.value.singleSessionPages + if (pages > currentMax) { + achievementTracker.updateSingleSessionPages(pages) + } + } + + /** + * Получить поток уведомлений о достижениях + */ + fun getAchievementNotifications(): Flow> { + return achievementTracker.notifications + } + + /** + * Очистить уведомления + */ + fun clearNotifications() { + achievementTracker.clearNotifications() + } + + /** + * Получить общий прогресс достижений + */ + fun getUserAchievements(): Flow { + return achievementTracker.userAchievements + } + + /** + * Получить прогресс конкретного достижения + */ + fun getAchievementProgress(achievementId: String): Flow { + return achievementTracker.achievementProgress.map { progressList -> + progressList.find { it.achievementId == achievementId } + }.distinctUntilChanged() + } +} + +/** + * Расширение для Flow + */ +private fun Flow.map(transform: (T) -> R): Flow { + return kotlinx.coroutines.flow.flow { + collect { value -> + emit(transform(value)) + } + } +} diff --git a/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/WeeklyChallengeTracker.kt b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/WeeklyChallengeTracker.kt new file mode 100644 index 000000000..4fd15e85c --- /dev/null +++ b/android/core-domain/src/main/java/io/leostrange/mrcomic/core/domain/analytics/WeeklyChallengeTracker.kt @@ -0,0 +1,169 @@ +package io.leostrange.mrcomic.core.domain.analytics + +import io.leostrange.mrcomic.core.model.WeeklyChallengeDefinitions +import io.leostrange.mrcomic.core.model.WeeklyChallengeProgress +import io.leostrange.mrcomic.core.model.WeeklyChallengeStatus +import io.leostrange.mrcomic.core.model.WeeklyChallengeType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Данные о прогрессе для еженедельных челленджей + */ +data class WeeklyChallengeData( + val pagesReadThisWeek: Int = 0, + val titlesCompletedThisWeek: Int = 0, + val streakDays: Int = 0, + val readingTimeMinutes: Int = 0, + val dailyGoalCompletedDays: Int = 0 +) + +/** + * Трекер еженедельных челленджей + */ +@Singleton +class WeeklyChallengeTracker @Inject constructor() { + + private val _progress = MutableStateFlow(WeeklyChallengeData()) + private val _challengeProgress = MutableStateFlow>(emptyMap()) + + /** + * Текущий прогресс + */ + val progress: Flow = _progress.asStateFlow() + + /** + * Прогресс челленджей + */ + val challengeProgress: Flow> = combine( + _progress, + _challengeProgress + ) { data, progressMap -> + WeeklyChallengeDefinitions.challenges.map { challenge -> + val current = when (challenge.type) { + WeeklyChallengeType.PAGES_READ -> data.pagesReadThisWeek + WeeklyChallengeType.TITLES_COMPLETED -> data.titlesCompletedThisWeek + WeeklyChallengeType.STREAK_DAYS -> data.streakDays + WeeklyChallengeType.READING_TIME -> data.readingTimeMinutes + WeeklyChallengeType.DAILY_GOAL -> data.dailyGoalCompletedDays + WeeklyChallengeType.NEW_GENRE -> 0 // TODO: Implement genre tracking + } + + val existing = progressMap[challenge.id] + val status = when { + existing?.status == WeeklyChallengeStatus.COMPLETED -> WeeklyChallengeStatus.COMPLETED + current >= challenge.target -> WeeklyChallengeStatus.COMPLETED + System.currentTimeMillis() > challenge.endDate -> WeeklyChallengeStatus.EXPIRED + else -> WeeklyChallengeStatus.ACTIVE + } + + WeeklyChallengeProgress( + challengeId = challenge.id, + current = current, + target = challenge.target, + status = status, + completedAt = if (status == WeeklyChallengeStatus.COMPLETED) { + existing?.completedAt ?: System.currentTimeMillis() + } else null + ) + } + } + + /** + * Обновить прогресс + */ + fun updateProgress(data: WeeklyChallengeData) { + _progress.value = data + checkForCompletedChallenges(data) + } + + /** + * Обновить конкретную метрику + */ + fun updatePagesRead(pages: Int) { + _progress.value = _progress.value.copy(pagesReadThisWeek = pages) + checkForCompletedChallenges(_progress.value) + } + + fun updateTitlesCompleted(titles: Int) { + _progress.value = _progress.value.copy(titlesCompletedThisWeek = titles) + checkForCompletedChallenges(_progress.value) + } + + fun updateStreakDays(days: Int) { + _progress.value = _progress.value.copy(streakDays = days) + checkForCompletedChallenges(_progress.value) + } + + fun updateReadingTime(minutes: Int) { + _progress.value = _progress.value.copy(readingTimeMinutes = minutes) + checkForCompletedChallenges(_progress.value) + } + + fun updateDailyGoalCompletedDays(days: Int) { + _progress.value = _progress.value.copy(dailyGoalCompletedDays = days) + checkForCompletedChallenges(_progress.value) + } + + /** + * Проверить завершённые челленджи + */ + private fun checkForCompletedChallenges(data: WeeklyChallengeData) { + val currentProgress = _challengeProgress.value.toMutableMap() + + WeeklyChallengeDefinitions.challenges.forEach { challenge -> + if (!currentProgress.containsKey(challenge.id)) { + val current = when (challenge.type) { + WeeklyChallengeType.PAGES_READ -> data.pagesReadThisWeek + WeeklyChallengeType.TITLES_COMPLETED -> data.titlesCompletedThisWeek + WeeklyChallengeType.STREAK_DAYS -> data.streakDays + WeeklyChallengeType.READING_TIME -> data.readingTimeMinutes + WeeklyChallengeType.DAILY_GOAL -> data.dailyGoalCompletedDays + WeeklyChallengeType.NEW_GENRE -> 0 + } + + if (current >= challenge.target) { + currentProgress[challenge.id] = WeeklyChallengeProgress( + challengeId = challenge.id, + current = current, + target = challenge.target, + status = WeeklyChallengeStatus.COMPLETED, + completedAt = System.currentTimeMillis() + ) + } + } + } + + _challengeProgress.value = currentProgress + } + + /** + * Получить завершённые челленджи + */ + fun getCompletedChallenges(): List { + return _challengeProgress.value.values.filter { + it.status == WeeklyChallengeStatus.COMPLETED + } + } + + /** + * Получить общий XP за завершённые челленджи + */ + fun getTotalXpEarned(): Int { + return getCompletedChallenges().sumOf { progress -> + WeeklyChallengeDefinitions.getById(progress.challengeId)?.xpReward ?: 0 + } + } + + /** + * Сбросить прогресс (вызывается в начале новой недели) + */ + fun resetWeeklyProgress() { + _progress.value = WeeklyChallengeData() + _challengeProgress.value = emptyMap() + } +} diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementDefinitions.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementDefinitions.kt new file mode 100644 index 000000000..2c766cae3 --- /dev/null +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementDefinitions.kt @@ -0,0 +1,313 @@ +package io.leostrange.mrcomic.core.model + +/** + * Определения всех достижений в системе + */ +object AchievementDefinitions { + + val allAchievements: List = listOf( + // === READING достижения === + Achievement( + id = "first_page", + category = AchievementCategory.READING, + rarity = AchievementRarity.COMMON, + title = "Первая страница", + description = "Прочитайте первую страницу", + xpReward = 10, + requirement = AchievementRequirement.PagesRead(1) + ), + Achievement( + id = "page_turner_100", + category = AchievementCategory.READING, + rarity = AchievementRarity.COMMON, + title = "Книжный червь", + description = "Прочитайте 100 страниц", + xpReward = 50, + requirement = AchievementRequirement.PagesRead(100) + ), + Achievement( + id = "page_turner_500", + category = AchievementCategory.READING, + rarity = AchievementRarity.UNCOMMON, + title = "Библиофил", + description = "Прочитайте 500 страниц", + xpReward = 100, + requirement = AchievementRequirement.PagesRead(500) + ), + Achievement( + id = "page_turner_1000", + category = AchievementCategory.READING, + rarity = AchievementRarity.RARE, + title = "Книголюб", + description = "Прочитайте 1000 страниц", + xpReward = 200, + requirement = AchievementRequirement.PagesRead(1000) + ), + Achievement( + id = "page_turner_5000", + category = AchievementCategory.READING, + rarity = AchievementRarity.EPIC, + title = "Мастер чтения", + description = "Прочитайте 5000 страниц", + xpReward = 500, + requirement = AchievementRequirement.PagesRead(5000) + ), + Achievement( + id = "page_turner_10000", + category = AchievementCategory.READING, + rarity = AchievementRarity.LEGENDARY, + title = "Легенда чтения", + description = "Прочитайте 10000 страниц", + xpReward = 1000, + requirement = AchievementRequirement.PagesRead(10000) + ), + + // === COLLECTION достижения === + Achievement( + id = "first_comic", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.COMMON, + title = "Первый тайтл", + description = "Добавьте первый комикс в библиотеку", + xpReward = 10, + requirement = AchievementRequirement.TitlesCompleted(1) + ), + Achievement( + id = "collector_5", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.COMMON, + title = "Коллекционер", + description = "Завершите 5 тайтлов", + xpReward = 50, + requirement = AchievementRequirement.TitlesCompleted(5) + ), + Achievement( + id = "collector_10", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.UNCOMMON, + title = "Библиотекарь", + description = "Завершите 10 тайтлов", + xpReward = 100, + requirement = AchievementRequirement.TitlesCompleted(10) + ), + Achievement( + id = "collector_25", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.RARE, + title = "Архивариус", + description = "Завершите 25 тайтлов", + xpReward = 200, + requirement = AchievementRequirement.TitlesCompleted(25) + ), + Achievement( + id = "collector_50", + category = AchievementCategory.COLLECTION, + rarity = AchievementRarity.EPIC, + title = "Хранитель знаний", + description = "Завершите 50 тайтлов", + xpReward = 500, + requirement = AchievementRequirement.TitlesCompleted(50) + ), + + // === STREAK достижения === + Achievement( + id = "streak_3", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.COMMON, + title = "Начало пути", + description = "Читайте 3 дня подряд", + xpReward = 30, + requirement = AchievementRequirement.StreakDays(3) + ), + Achievement( + id = "streak_7", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.UNCOMMON, + title = "Недельный ритм", + description = "Читайте 7 дней подряд", + xpReward = 70, + requirement = AchievementRequirement.StreakDays(7) + ), + Achievement( + id = "streak_14", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.RARE, + title = "Двухнедельный марафон", + description = "Читайте 14 дней подряд", + xpReward = 140, + requirement = AchievementRequirement.StreakDays(14) + ), + Achievement( + id = "streak_30", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.EPIC, + title = "Месяц чтения", + description = "Читайте 30 дней подряд", + xpReward = 300, + requirement = AchievementRequirement.StreakDays(30) + ), + Achievement( + id = "streak_100", + category = AchievementCategory.STREAK, + rarity = AchievementRarity.LEGENDARY, + title = "Легенда постоянства", + description = "Читайте 100 дней подряд", + xpReward = 1000, + requirement = AchievementRequirement.StreakDays(100) + ), + + // === EXPLORATION достижения === + Achievement( + id = "marathon_reader", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.UNCOMMON, + title = "Марафонец", + description = "Прочитайте 100 страниц за одну сессию", + xpReward = 80, + requirement = AchievementRequirement.SingleSessionPages(100) + ), + Achievement( + id = "weekly_champion", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Чемпион недели", + description = "Выполните недельную цель 4 раза", + xpReward = 200, + requirement = AchievementRequirement.WeeklyGoalCompleted(4) + ), + Achievement( + id = "time_reader_60", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.UNCOMMON, + title = "Час чтения", + description = "Проведите 60 минут за чтением", + xpReward = 60, + requirement = AchievementRequirement.ReadingTimeMinutes(60) + ), + Achievement( + id = "time_reader_300", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Пять часов", + description = "Проведите 300 минут за чтением", + xpReward = 150, + requirement = AchievementRequirement.ReadingTimeMinutes(300) + ), + Achievement( + id = "time_reader_1000", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.EPIC, + title = "Тысяча минут", + description = "Проведите 1000 минут за чтением", + xpReward = 500, + requirement = AchievementRequirement.ReadingTimeMinutes(1000) + ), + + // === MILESTONE достижения === + Achievement( + id = "mascot_teen", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.UNCOMMON, + title = "Подросток", + description = "Достигните стадии TEEN для маскота", + xpReward = 100, + requirement = AchievementRequirement.MascotStage(MascotStage.TEEN) + ), + Achievement( + id = "mascot_young", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.RARE, + title = "Юность", + description = "Достигните стадии YOUNG для маскота", + xpReward = 200, + requirement = AchievementRequirement.MascotStage(MascotStage.YOUNG) + ), + Achievement( + id = "mascot_adult", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.EPIC, + title = "Взрослый", + description = "Достигните стадии ADULT для маскота", + xpReward = 500, + requirement = AchievementRequirement.MascotStage(MascotStage.ADULT) + ), + Achievement( + id = "xp_1000", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.UNCOMMON, + title = "Тысяча XP", + description = "Накопите 1000 XP", + xpReward = 100, + requirement = AchievementRequirement.TotalXp(1000) + ), + Achievement( + id = "xp_5000", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.RARE, + title = "Пять тысяч XP", + description = "Накопите 5000 XP", + xpReward = 300, + requirement = AchievementRequirement.TotalXp(5000) + ), + Achievement( + id = "xp_10000", + category = AchievementCategory.MILESTONE, + rarity = AchievementRarity.EPIC, + title = "Десять тысяч XP", + description = "Накопите 10000 XP", + xpReward = 500, + requirement = AchievementRequirement.TotalXp(10000) + ), + + // === Секретные достижения === + Achievement( + id = "secret_night_owl", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Сова", + description = "Читайте после полуночи", + xpReward = 100, + requirement = AchievementRequirement.PagesRead(10), // Упрощённое условие + isSecret = true + ), + Achievement( + id = "secret_early_bird", + category = AchievementCategory.EXPLORATION, + rarity = AchievementRarity.RARE, + title = "Ранняя пташка", + description = "Читайте до 6 утра", + xpReward = 100, + requirement = AchievementRequirement.PagesRead(10), // Упрощённое условие + isSecret = true + ) + ) + + /** + * Получить достижение по ID + */ + fun getById(id: String): Achievement? = allAchievements.find { it.id == id } + + /** + * Получить достижения по категории + */ + fun getByCategory(category: AchievementCategory): List = + allAchievements.filter { it.category == category } + + /** + * Получить достижения по редкости + */ + fun getByRarity(rarity: AchievementRarity): List = + allAchievements.filter { it.rarity == rarity } + + /** + * Получить несекретные достижения + */ + fun getVisible(): List = + allAchievements.filter { !it.isSecret } + + /** + * Получить секретные достижения + */ + fun getSecret(): List = + allAchievements.filter { it.isSecret } +} diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementModels.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementModels.kt new file mode 100644 index 000000000..15dc32cee --- /dev/null +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/AchievementModels.kt @@ -0,0 +1,99 @@ +package io.leostrange.mrcomic.core.model + +/** + * Типы достижений в системе геймификации + */ +enum class AchievementCategory { + READING, // Достижения связанные с чтением + COLLECTION, // Достижения связанные с коллекцией + STREAK, // Достижения связанные с сериями + EXPLORATION, // Достижения связанные с исследованием + MILESTONE // Веховые достижения +} + +/** + * Редкость достижения + */ +enum class AchievementRarity { + COMMON, // Обычное + UNCOMMON, // Необычное + RARE, // Редкое + EPIC, // Эпическое + LEGENDARY // Легендарное +} + +/** + * Статус достижения + */ +enum class AchievementStatus { + LOCKED, // Заблокировано + UNLOCKED, // Разблокировано + CLAIMED // Получено +} + +/** + * Модель достижения + */ +data class Achievement( + val id: String, + val category: AchievementCategory, + val rarity: AchievementRarity, + val title: String, + val description: String, + val iconRes: String? = null, + val xpReward: Int = 0, + val requirement: AchievementRequirement, + val isSecret: Boolean = false +) + +/** + * Требования для получения достижения + */ +sealed class AchievementRequirement { + data class PagesRead(val pages: Int) : AchievementRequirement() + data class TitlesCompleted(val titles: Int) : AchievementRequirement() + data class StreakDays(val days: Int) : AchievementRequirement() + data class ReadingTimeMinutes(val minutes: Int) : AchievementRequirement() + data class GenresExplored(val genres: Int) : AchievementRequirement() + data class SingleSessionPages(val pages: Int) : AchievementRequirement() + data class WeeklyGoalCompleted(val weeks: Int) : AchievementRequirement() + data class MascotStage( + val stage: io.leostrange.mrcomic.core.model.MascotStage, + ) : AchievementRequirement() + data class TotalXp(val xp: Int) : AchievementRequirement() + data class And(val requirements: List) : AchievementRequirement() + data class Or(val requirements: List) : AchievementRequirement() +} + +/** + * Прогресс достижения + */ +data class AchievementProgress( + val achievementId: String, + val status: AchievementStatus, + val currentProgress: Float, // 0.0 - 1.0 + val unlockedAt: Long? = null, + val claimedAt: Long? = null +) + +/** + * Состояние всех достижений пользователя + */ +data class UserAchievements( + val achievements: List, + val totalXpEarned: Int, + val unlockedCount: Int, + val totalCount: Int +) { + val completionRate: Float + get() = if (totalCount == 0) 0f else unlockedCount.toFloat() / totalCount.toFloat() +} + +/** + * Уведомление о новом достижении + */ +data class AchievementNotification( + val achievement: Achievement, + val xpEarned: Int, + val timestamp: Long +) diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt index 3e494965a..e3de6c44b 100644 --- a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/Comic.kt @@ -65,15 +65,36 @@ fun Comic.readingStatus(): ComicReadingStatus { storedReaderLocator() != null val progressHasAuthority = normalizedProgress > 0.001f && (hasStableReadingSignal || normalizedPageCount > 1) - val completedByProgress = normalizedProgress >= 0.999f && - (hasStableReadingSignal || normalizedPageCount > 1) + // Reflowable formats (especially legacy RTF records) can persist a stale + // `readingProgress = 1` before pagination has produced a real position. + // A known page count makes the page index authoritative; only an explicit + // completion flag or the last confirmed page may complete the book. + val completedByProgress = normalizedProgress >= 0.999f && when { + normalizedPageCount > 1 -> hasStableReadingSignal && normalizedCurrentPage >= normalizedPageCount - 1 + format.isTextReadingFormat() -> { + val loc = storedReaderLocator() + loc?.progression != null && loc.progression!! >= 0.99f + } + else -> hasStableReadingSignal + } + // BUG-B3: For reflowable text formats whose page count is still unknown + // (pageCount <= 1, i.e. pagination hasn't resolved yet), a stale + // readingProgress=1 must not mark the book as COMPLETED unless a real + // reader locator (href/progression) anchors the end position. Neither + // lastReadDate ("was opened once") nor a bare currentPage index prove the + // end was reached while the total page count is unknown. + val reflowableUnverifiedCompletion = format.isTextReadingFormat() && + normalizedPageCount <= 1 && + completedByProgress && + (storedReaderLocator()?.progression?.let { it < 0.99f } ?: true) + val effectiveCompleted = completedByProgress && !reflowableUnverifiedCompletion val hasReadingActivity = hasStableReadingSignal || progressHasAuthority // Completion is driven only by an explicit flag or a confirmed full-progress // signal. Landing on the last page index (currentPage >= pageCount - 1) is no // longer a completion condition: a crash, a deferred-count clamp or a text book // parked on its final section must not fake a 100% badge. return when { - isCompleted || completedByProgress -> ComicReadingStatus.COMPLETED + isCompleted || effectiveCompleted -> ComicReadingStatus.COMPLETED hasReadingActivity -> ComicReadingStatus.READING else -> ComicReadingStatus.NEW } @@ -94,13 +115,39 @@ fun readingProgressForPage(currentPage: Int, pageCount: Int): Float { } /** Progress suitable for display, including protection from legacy placeholder 100% values. */ -fun Comic.displayReadingProgress(): Float = when (readingStatus()) { +fun Comic.displayReadingProgress(): Float { + // RTF files frequently arrive from legacy imports with a stale 1.0 value. + // Do not expose that placeholder until the reader has persisted a real + // position or the title was explicitly completed. + if (format == ComicFormat.RTF && + storedReaderLocator()?.progression == null && readingProgress >= 0.999f && + !(pageCount > 1 && currentPage >= pageCount - 1) && + pageCount <= 1 + ) { + return 0f + } + return when (readingStatus()) { ComicReadingStatus.NEW -> 0f ComicReadingStatus.COMPLETED -> 1f - ComicReadingStatus.READING -> maxOf( - readingProgress.coerceIn(0f, 1f), - readingProgressForPage(currentPage, pageCount) - ) + ComicReadingStatus.READING -> { + // Canonical progress is always derived from currentPage/pageCount when pageCount > 1. + // For legacy data where pageCount is unknown (0), fall back to stored readingProgress + // so existing library cards and progress bars still show meaningful values. + if (pageCount > 1) { + readingProgressForPage(currentPage, pageCount) + } else if (format.isTextReadingFormat()) { + val loc = storedReaderLocator() + loc?.progression?.toFloat()?.coerceIn(0f, 1f) ?: 0f + } else { + // Page count is unresolved (≤ 1): stored readingProgress may be a stale + // placeholder from legacy records or backup merges. A full-progress value + // (≥ 0.999) without a real reader locator proves no actual reading occurred, + // so show 0 % instead of a misleading 100 % badge. + val stored = readingProgress.coerceIn(0f, 1f) + if (stored >= 0.999f && storedReaderLocator() == null) 0f else stored + } + } +} } fun Comic.storedReaderLocator(): ReaderLocator? { @@ -154,7 +201,6 @@ enum class ComicFormat { ODT, CHM, XPS, - OPDS, DJVU, FOLDER, UNKNOWN diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/OpdsModels.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/OpdsModels.kt deleted file mode 100644 index b0392e450..000000000 --- a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/OpdsModels.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.leostrange.mrcomic.core.model - -/** - * OPDS (Open Publication Distribution System) data models. - * - * OPDS is an Atom/XML-based standard for distributing electronic books. - * Catalogs contain feeds with navigation links and book acquisition entries. - */ - -/** A top-level OPDS feed (Atom document). */ -data class OpdsFeed( - val title: String, - val entries: List, - val links: List, - val nextLink: String? = null, - val searchLink: String? = null -) - -/** A single entry in an OPDS feed (Atom ). */ -data class OpdsEntry( - val title: String, - val author: String? = null, - val summary: String? = null, - val thumbnailUrl: String? = null, - val updated: String? = null, - val links: List = emptyList() -) { - /** Returns the first acquisition link (downloadable book). */ - val acquisitionLink: OpdsLink? - get() = links.firstOrNull { it.isAcquisition } - - /** Returns the first navigation link (sub-catalog). */ - val navigationLink: OpdsLink? - get() = links.firstOrNull { it.isNavigation } - - /** Whether this entry is a downloadable book. */ - val isBook: Boolean - get() = acquisitionLink != null - - /** Whether this entry is a sub-catalog / category. */ - val isCatalog: Boolean - get() = navigationLink != null && !isBook -} - -/** A link within an OPDS entry or feed. */ -data class OpdsLink( - val href: String, - val rel: String, - val type: String? = null, - val title: String? = null -) { - /** Whether this link is an acquisition (download) link. */ - val isAcquisition: Boolean - get() = rel.startsWith("http://opds-spec.org/acquisition") || - rel == "http://opds-spec.org/acquisition/open-access" - - /** Whether this link is a navigation (sub-catalog) link. */ - val isNavigation: Boolean - get() = rel == "subsection" || rel == "alternate" || - rel == "http://opds-spec.org/featured" || - rel == "http://opds-spec.org/new" || - rel == "http://opds-spec.org/popular" - - /** Whether this link is a search link. */ - val isSearch: Boolean - get() = rel == "search" || rel == "http://opds-spec.org/search" - - /** Whether this link points to a next page. */ - val isNext: Boolean - get() = rel == "next" - - /** Whether this link is a thumbnail image. */ - val isThumbnail: Boolean - get() = rel == "http://opds-spec.org/image/thumbnail" || - rel == "http://opds-spec.org/thumbnail" -} - -/** A pre-configured OPDS catalog source. */ -data class OpdsCatalogSource( - val name: String, - val url: String, - val description: String = "", - val isSearchable: Boolean = false -) diff --git a/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/WeeklyChallengeModels.kt b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/WeeklyChallengeModels.kt new file mode 100644 index 000000000..764e61359 --- /dev/null +++ b/android/core-model/src/main/java/io/leostrange/mrcomic/core/model/WeeklyChallengeModels.kt @@ -0,0 +1,136 @@ +package io.leostrange.mrcomic.core.model + +/** + * Типы еженедельных челленджей + */ +enum class WeeklyChallengeType { + PAGES_READ, // Прочитать определённое количество страниц + TITLES_COMPLETED, // Завершить определённое количество тайтлов + STREAK_DAYS, // Поддерживать серию дней + READING_TIME, // Провести определённое время за чтением + DAILY_GOAL, // Выполнить ежедневную цель несколько дней + NEW_GENRE // Прочитать тайтл нового жанра +} + +/** + * Статус еженедельного челленджа + */ +enum class WeeklyChallengeStatus { + ACTIVE, // Активный + COMPLETED, // Завершённый + FAILED, // Проваленный + EXPIRED // Истёкший +} + +/** + * Модель еженедельного челленджа + */ +data class WeeklyChallenge( + val id: String, + val type: WeeklyChallengeType, + val title: String, + val description: String, + val target: Int, + val xpReward: Int, + val startDate: Long, + val endDate: Long, + val status: WeeklyChallengeStatus = WeeklyChallengeStatus.ACTIVE +) + +/** + * Прогресс еженедельного челленджа + */ +data class WeeklyChallengeProgress( + val challengeId: String, + val current: Int, + val target: Int, + val status: WeeklyChallengeStatus, + val completedAt: Long? = null +) { + val progress: Float + get() = if (target == 0) 0f else (current.toFloat() / target).coerceIn(0f, 1f) + + val isCompleted: Boolean + get() = status == WeeklyChallengeStatus.COMPLETED +} + +/** + * Определения еженедельных челленджей + */ +object WeeklyChallengeDefinitions { + + val challenges: List = listOf( + WeeklyChallenge( + id = "weekly_pages_100", + type = WeeklyChallengeType.PAGES_READ, + title = "Стостраничный марафон", + description = "Прочитайте 100 страниц за неделю", + target = 100, + xpReward = 100, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_pages_300", + type = WeeklyChallengeType.PAGES_READ, + title = "Трёхсотстраничный вызов", + description = "Прочитайте 300 страниц за неделю", + target = 300, + xpReward = 300, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_titles_2", + type = WeeklyChallengeType.TITLES_COMPLETED, + title = "Двойной удар", + description = "Завершите 2 тайтла за неделю", + target = 2, + xpReward = 150, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_streak_5", + type = WeeklyChallengeType.STREAK_DAYS, + title = "Пятидневная серия", + description = "Читайте 5 дней подряд", + target = 5, + xpReward = 120, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_time_60", + type = WeeklyChallengeType.READING_TIME, + title = "Часовой марафон", + description = "Проведите 60 минут за чтением", + target = 60, + xpReward = 80, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ), + WeeklyChallenge( + id = "weekly_daily_5", + type = WeeklyChallengeType.DAILY_GOAL, + title = "Пятидневная цель", + description = "Выполните ежедневную цель 5 дней", + target = 5, + xpReward = 200, + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000 + ) + ) + + /** + * Получить челлендж по ID + */ + fun getById(id: String): WeeklyChallenge? = challenges.find { it.id == id } + + /** + * Получить активные челленджи + */ + fun getActive(): List = challenges.filter { + it.status == WeeklyChallengeStatus.ACTIVE + } +} diff --git a/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt b/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt index a488add7c..800a8ded9 100644 --- a/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt +++ b/android/core-model/src/test/java/io/leostrange/mrcomic/core/model/ComicReadingStatusTest.kt @@ -90,6 +90,30 @@ class ComicReadingStatusTest { assertTrue(comic.isReadCompleted()) } + @Test + fun rtfSingleSectionBookDoesNotFalseCompleteWhenOpened() { + val rtfOpened = Comic( + format = ComicFormat.RTF, + pageCount = 1, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L + ) + assertFalse(rtfOpened.isReadCompleted()) + assertTrue(rtfOpened.isReadingInProgress()) + assertEquals(0f, rtfOpened.displayReadingProgress()) + + val rtfWithEndLocator = Comic( + format = ComicFormat.RTF, + pageCount = 1, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L, + readerLocatorProgression = 0.995 + ) + assertTrue(rtfWithEndLocator.isReadCompleted()) + } + @Test fun placeholderFullProgressWithoutAnyReadingSignalStaysNew() { val comic = Comic(pageCount = 1, currentPage = 0, readingProgress = 1f, lastReadDate = null) @@ -97,4 +121,186 @@ class ComicReadingStatusTest { assertEquals(ComicReadingStatus.NEW, comic.readingStatus()) assertFalse(comic.isReadCompleted()) } + + // ── BUG-READER-04: displayReadingProgress() consistency ── + + @Test + fun displayReadingProgress_completedBookAlwaysReturnsOne() { + // Explicit isCompleted must always yield 1f regardless of page values. + val comic = Comic(pageCount = 100, currentPage = 0, isCompleted = true) + assertEquals(1f, comic.displayReadingProgress()) + } + + @Test + fun displayReadingProgress_readingBookDerivesFromPageCount() { + // Canonical progress is always derived from currentPage/pageCount when pageCount > 1. + val comic = Comic(pageCount = 200, currentPage = 100, lastReadDate = 123L) + // readingProgressForPage(100, 200) = 100 / 199 ≈ 0.5025 + assertEquals(0.5025f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_ignoresStoredProgressWhenPageCountKnown() { + // Even if stored readingProgress is stale/wrong, pageCount-based calculation wins. + val comic = Comic(pageCount = 100, currentPage = 50, readingProgress = 0.99f, lastReadDate = 123L) + val progress = comic.displayReadingProgress() + // readingProgressForPage(50, 100) = 50/99 ≈ 0.505 + assertEquals(0.505f, progress, 0.001f) + } + + @Test + fun displayReadingProgress_fallsBackToStoredProgressWhenPageCountZero() { + // Legacy records with pageCount=0 use stored readingProgress as fallback. + val comic = Comic(pageCount = 0, currentPage = 0, readingProgress = 0.45f, lastReadDate = 123L) + assertEquals(0.45f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_staleFullProgressWithUnknownPageCountShowsZero() { + // RTF/EPUB with pageCount=0 and readingProgress=1.0 (stale) but no reader locator + // must display 0 %, not 100 %. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L, + ) + assertEquals(0f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_nonStaleProgressWithUnknownPageCountStillFallsBack() { + // Genuine partial progress (< 1.0) with unresolved page count should be preserved. + val comic = Comic(pageCount = 0, currentPage = 0, readingProgress = 0.3f, lastReadDate = 123L) + assertEquals(0.3f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun displayReadingProgress_newBookReturnsZero() { + val comic = Comic(pageCount = 100, currentPage = 0, lastReadDate = null) + assertEquals(0f, comic.displayReadingProgress()) + } + + @Test + fun staleKnownPageCountProgressDoesNotMarkRtfAsRead() { + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 370, + currentPage = 0, + readingProgress = 1f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + assertEquals(0f, comic.displayReadingProgress()) + } + + @Test + fun rtfIsCompletedOnlyOnLastConfirmedPage() { + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 370, + currentPage = 369, + readingProgress = 1f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.COMPLETED, comic.readingStatus()) + assertEquals(1f, comic.displayReadingProgress()) + } + + // ── BUG-B3: reflowable with unresolved pageCount must not fake 100% ── + + @Test + fun rtfWithUnresolvedPageCountAndStaleProgressIsNotCompleted() { + // RTF opened once (lastReadDate set) but never actually read: + // pageCount=0 (not yet paginated), readingProgress=1.0 (stale legacy), + // currentPage=0, locator=null → must NOT be COMPLETED. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + // Stale full-progress without a real reader locator → display shows 0 %, not 100 %. + assertEquals(0f, comic.displayReadingProgress(), 0.001f) + } + + @Test + fun rtfWithPageCountOneAndStaleProgressIsNotCompleted() { + // Same scenario but pageCount=1 (single unresolved section). + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 1, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 456L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + } + + @Test + fun rtfWithUnresolvedPageCountButRealLocatorIsCompleted() { + // RTF with stale progress=1 BUT a real locator → genuinely read to the end. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 123L, + readerLocatorHref = "chapter-5.xhtml", + readerLocatorProgression = 1.0, + ) + + assertEquals(ComicReadingStatus.COMPLETED, comic.readingStatus()) + } + + @Test + fun rtfWithUnresolvedPageCountButRealCurrentPageIsNotCompleted() { + // RTF with stale progress=1, no locator, but currentPage > 0 + // → has been read, but pageCount unknown → still READING. + val comic = Comic( + format = ComicFormat.RTF, + pageCount = 0, + currentPage = 5, + readingProgress = 1.0f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + } + + @Test + fun epubWithUnresolvedPageCountAndStaleProgressIsNotCompleted() { + // Same guard applies to EPUB and other text formats with unresolved page counts. + val comic = Comic( + format = ComicFormat.EPUB, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 789L, + ) + + assertEquals(ComicReadingStatus.READING, comic.readingStatus()) + } + + @Test + fun nonTextFormatWithUnresolvedPageCountCanStillCompleteByProgress() { + // Non-text formats (e.g. UNKNOWN) with stale progress=1 and lastReadDate + // should still complete — the reflowable guard doesn't apply. + val comic = Comic( + format = ComicFormat.UNKNOWN, + pageCount = 0, + currentPage = 0, + readingProgress = 1.0f, + lastReadDate = 123L, + ) + + assertEquals(ComicReadingStatus.COMPLETED, comic.readingStatus()) + } } diff --git a/android/core-ui/build.gradle.kts b/android/core-ui/build.gradle.kts index 35cdc12c3..a24bed7e8 100644 --- a/android/core-ui/build.gradle.kts +++ b/android/core-ui/build.gradle.kts @@ -16,6 +16,7 @@ android { } dependencies { implementation(project(":core-model")) + implementation(project(":core-interfaces")) api(platform(libs.androidx.compose.bom)) api(libs.androidx.compose.ui) api(libs.androidx.compose.ui.tooling.preview) diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt index 89109337a..44ccf37c8 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Buttons.kt @@ -29,7 +29,7 @@ fun MrComicButton( contentPadding: PaddingValues = PaddingValues(horizontal = 22.dp, vertical = 10.dp), content: @Composable RowScope.() -> Unit ) { - val shape = RoundedCornerShape(MrComicRadiusTokens.pill) + val shape = RoundedCornerShape(MrComicCornerScale.pill) when (variant) { MrComicButtonVariant.Filled -> Button( onClick = onClick, @@ -63,7 +63,7 @@ fun MrComicButton( enabled = enabled, shape = shape, contentPadding = contentPadding, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.44f)), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = MrComicAlphaTokens.Subtle)), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.primary ), diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt index c57aa948a..1ee3d47e0 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Cards.kt @@ -8,52 +8,23 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -@Composable -fun MrComicPanelCard( - title: String, - modifier: Modifier = Modifier, - hint: String? = null, - content: @Composable ColumnScope.() -> Unit -) { - val colorScheme = MaterialTheme.colorScheme - val lightChrome = colorScheme.background.luminance() > 0.45f - OutlinedCard( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(MrComicRadiusTokens.xl), - border = BorderStroke( - width = 1.dp, - color = colorScheme.outlineVariant.copy(alpha = if (lightChrome) 0.44f else 0.28f) - ), - colors = CardDefaults.outlinedCardColors( - containerColor = colorScheme.surface.copy(alpha = if (lightChrome) 0.94f else 0.9f) - ) - ) { - Column( - modifier = Modifier.padding(horizontal = MrComicSpacingTokens.x4, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(9.dp) - ) { - Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - if (!hint.isNullOrBlank()) { - Text(hint, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - content() - } - } -} +/** + * Editorial Ink cards. + * + * `MrComicPanelCard` was removed in the Editorial Ink migration. Use + * [MrComicSectionHeader] + flat content (a Column of [MrComicListItem] rows) for + * section groups, or [MrComicCardSurface] / [MrComicSurfaceCard] for visual + * surfaces that need a container. + */ @Composable fun MrComicCard( @@ -65,7 +36,7 @@ fun MrComicCard( contentColor: Color = MaterialTheme.colorScheme.onSurface, border: BorderStroke? = null, shape: Shape? = null, - cornerRadius: Dp = MrComicRadiusTokens.xl, + cornerRadius: Dp = MrComicCornerScale.xl, tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, contentPadding: PaddingValues = PaddingValues(MrComicSpacingTokens.x4), @@ -103,21 +74,29 @@ fun MrComicCardSurface( contentColor: Color = MaterialTheme.colorScheme.onSurface, border: BorderStroke? = null, shape: Shape? = null, - cornerRadius: Dp = MrComicRadiusTokens.xl, + cornerRadius: Dp = MrComicCornerScale.xl, tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, content: @Composable () -> Unit ) { val colorScheme = MaterialTheme.colorScheme + // Editorial Ink: container roles are pure M3 tones, no alpha dimming. + // Material 3 already exposes surfaceContainer*, primaryContainer, etc. + // with predictable luminance, so transparent overlays are no longer + // needed to keep contrast on decorative backgrounds. val resolvedContainer = containerColor ?: when { - selected -> colorScheme.primaryContainer.copy(alpha = 0.84f) - variant == MrComicCardVariant.Muted -> colorScheme.surfaceContainerHigh.copy(alpha = 0.62f) - variant == MrComicCardVariant.Primary -> colorScheme.primaryContainer.copy(alpha = 0.42f) - variant == MrComicCardVariant.Secondary -> colorScheme.secondaryContainer.copy(alpha = 0.34f) - variant == MrComicCardVariant.Tertiary -> colorScheme.tertiaryContainer.copy(alpha = 0.26f) + selected -> colorScheme.primaryContainer + variant == MrComicCardVariant.Muted -> colorScheme.surfaceContainerLow + variant == MrComicCardVariant.Primary -> colorScheme.primaryContainer + variant == MrComicCardVariant.Secondary -> colorScheme.secondaryContainer + variant == MrComicCardVariant.Tertiary -> colorScheme.tertiaryContainer else -> colorScheme.surface } - val resolvedBorder = border ?: if (selected) BorderStroke(1.dp, colorScheme.primary.copy(alpha = 0.34f)) else null + val resolvedBorder = border ?: if (selected) { + BorderStroke(1.dp, colorScheme.primary.copy(alpha = MrComicAlphaTokens.Subtle)) + } else { + null + } Surface( modifier = if (fillMaxWidth) modifier.fillMaxWidth() else modifier, shape = shape ?: RoundedCornerShape(cornerRadius), @@ -139,12 +118,14 @@ fun MrComicSurfaceCard( verticalSpacing: Dp = MrComicSpacingTokens.x1, content: @Composable ColumnScope.() -> Unit ) { + // Editorial Ink: surfaceContainerHigh is a pure M3 tone (no alpha dimming). + val colorScheme = MaterialTheme.colorScheme MrComicCard( modifier = modifier, fillMaxWidth = fillMaxWidth, selected = selected, - containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.84f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.98f), - cornerRadius = MrComicRadiusTokens.lg, + containerColor = if (selected) colorScheme.primaryContainer else colorScheme.surfaceContainerHigh, + cornerRadius = MrComicCornerScale.lg, contentPadding = contentPadding, verticalSpacing = verticalSpacing, content = content diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt index 29084b165..e53dbca26 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Chips.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -41,7 +42,7 @@ fun MrComicFilterChip( onClick = onClick, enabled = enabled, modifier = modifier.heightIn(min = minHeight), - shape = RoundedCornerShape(MrComicRadiusTokens.pill), + shape = RoundedCornerShape(MrComicCornerScale.pill), colors = FilterChipDefaults.filterChipColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, @@ -66,7 +67,7 @@ fun MrComicPill( ) { Surface( modifier = modifier, - shape = RoundedCornerShape(MrComicRadiusTokens.pill), + shape = RoundedCornerShape(MrComicCornerScale.pill), color = containerColor, contentColor = contentColor, border = border @@ -82,25 +83,42 @@ fun MrComicPill( @Composable fun MrComicFormatBadge(label: String, isGraphic: Boolean, modifier: Modifier = Modifier) { - val container = MaterialTheme.colorScheme.surface.copy(alpha = if (isGraphic) 0.9f else 0.92f) - val borderAlpha = if (isGraphic) 0.3f else 0.26f - Surface( + val colorScheme = MaterialTheme.colorScheme + // BUG-UI-01: Ensure sufficient contrast for badge text on all backgrounds. + val containerColor = colorScheme.surface.copy(alpha = if (isGraphic) 0.92f else 0.94f) + val contentColor = ensureBadgeContrast(colorScheme.onSurface, containerColor) + MrComicPill( modifier = modifier, - shape = RoundedCornerShape(MrComicRadiusTokens.sm), - color = container, - border = BorderStroke(0.6.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = borderAlpha)) + containerColor = containerColor, + contentColor = contentColor, + border = BorderStroke(0.6.dp, colorScheme.outlineVariant.copy(alpha = if (isGraphic) 0.35f else 0.3f)), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 3.dp) ) { Text( text = label, - modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall.copy(fontSize = MrComicTypographyTokens.badge), - color = MaterialTheme.colorScheme.onSurface, + color = contentColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } +/** + * Ensures badge text has sufficient contrast against its container. + * Falls back to black or white if the contrast ratio is below 3:1. + */ +private fun ensureBadgeContrast(foreground: Color, background: Color): Color { + val bgLum = background.luminance().coerceIn(0.001f, 0.999f) + val fgLum = foreground.luminance().coerceIn(0.001f, 0.999f) + val ratio = if (bgLum > fgLum) (bgLum + 0.05f) / (fgLum + 0.05f) + else (fgLum + 0.05f) / (bgLum + 0.05f) + if (ratio >= 3f) return foreground + val blackRatio = (bgLum + 0.05f) / (0.0f + 0.05f) + val whiteRatio = (1.0f + 0.05f) / (bgLum + 0.05f) + return if (blackRatio >= whiteRatio) Color.Black else Color.White +} + @Composable fun MrComicStatusBadge( text: String, @@ -113,11 +131,11 @@ fun MrComicStatusBadge( ) { val colorScheme = MaterialTheme.colorScheme val (toneContainerColor, toneContentColor) = when (tone) { - MrComicStatusTone.Neutral -> colorScheme.surface.copy(alpha = 0.9f) to colorScheme.onSurface - MrComicStatusTone.Info -> colorScheme.primary.copy(alpha = 0.14f) to colorScheme.primary - MrComicStatusTone.Success -> colorScheme.tertiary.copy(alpha = 0.16f) to colorScheme.tertiary - MrComicStatusTone.Warning -> colorScheme.secondary.copy(alpha = 0.18f) to colorScheme.secondary - MrComicStatusTone.Error -> colorScheme.error.copy(alpha = 0.14f) to colorScheme.error + MrComicStatusTone.Neutral -> colorScheme.surface.copy(alpha = 0.92f) to colorScheme.onSurface + MrComicStatusTone.Info -> colorScheme.primary.copy(alpha = 0.22f) to colorScheme.primary + MrComicStatusTone.Success -> colorScheme.tertiary.copy(alpha = 0.24f) to colorScheme.tertiary + MrComicStatusTone.Warning -> colorScheme.secondary.copy(alpha = 0.24f) to colorScheme.secondary + MrComicStatusTone.Error -> colorScheme.error.copy(alpha = 0.22f) to colorScheme.error } val resolvedContainerColor = containerColor ?: toneContainerColor val resolvedContentColor = contentColor ?: toneContentColor diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt index fd36919fa..b872d6b90 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Controls.kt @@ -1,29 +1,20 @@ package io.leostrange.mrcomic.core.ui.designsystem -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp +/** + * Editorial Ink control primitives. + * + * `MrComicSwitchRow` and `MrComicSliderTile` were removed in the Editorial Ink + * migration; use `MrComicListItem` with [MrComicListItemTrailing.Switch] and a + * `MrComicSlider` row instead. + */ @Composable fun MrComicProgressLine(progress: () -> Float, modifier: Modifier = Modifier, color: Color? = null, trackColor: Color? = null) { LinearProgressIndicator( @@ -66,66 +57,3 @@ fun mrComicCompletedColor(): Color = if (MaterialTheme.colorScheme.background.lu } else { mrComicArgbColor(MrComicColorTokens.InkPaperDarkCompletedArgb) } - -@Composable -fun MrComicSwitchRow( - title: String, - subtitle: String? = null, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - switchScale: Float = 1f -) { - Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(20.dp)) { - androidx.compose.foundation.layout.Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleSmall, color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)) - if (!subtitle.isNullOrBlank()) { - Spacer(Modifier.height(2.dp)) - Text(subtitle, style = MaterialTheme.typography.bodySmall, color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)) - } - } - Spacer(Modifier.width(MrComicSpacingTokens.x3)) - Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange, modifier = Modifier.scale(switchScale)) - } - } -} - -@Composable -fun MrComicSliderTile( - title: String, - valueLabel: String, - value: Float, - onValueChange: (Float) -> Unit, - valueRange: ClosedFloatingPointRange, - modifier: Modifier = Modifier, - steps: Int = 0, - subtitle: String? = null, - enabled: Boolean = true -) { - Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(20.dp)) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x2) - ) { - androidx.compose.foundation.layout.Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleSmall) - if (!subtitle.isNullOrBlank()) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.width(MrComicSpacingTokens.x3)) - Text(valueLabel, modifier = Modifier.widthIn(max = 112.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) - } - Slider(value = value, onValueChange = onValueChange, valueRange = valueRange, steps = steps, enabled = enabled, modifier = Modifier.fillMaxWidth()) - } - } -} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicAlphaTokens.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicAlphaTokens.kt new file mode 100644 index 000000000..f008b75e5 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicAlphaTokens.kt @@ -0,0 +1,22 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +/** + * Semantic opacity levels for the Editorial Ink design system. + * + * Use these instead of raw `.copy(alpha = 0.42f)` so the visual language is + * consistent and tunable in one place. Existing screens still use raw alpha + * values; new components should prefer these tokens. + */ +object MrComicAlphaTokens { + /** Faint, ghosted — borders, dividers, disabled icon containers. */ + const val Subtle: Float = 0.6f + + /** Light veil — secondary containers, scrims, badges. */ + const val Soft: Float = 0.8f + + /** Solid — primary surfaces, primary text on background. */ + const val Solid: Float = 1.0f + + /** Half-strength divider — used by inline list separators. */ + const val Hairline: Float = 0.3f +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicBottomBar.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicBottomBar.kt new file mode 100644 index 000000000..9f3f521c5 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicBottomBar.kt @@ -0,0 +1,154 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Editorial Ink bottom navigation bar. + * + * • 64 dp content height plus the navigation-bars inset. + * • Indicator: 64×32 dp pill with 14 dp radius, [MrComicCornerScale.lg]. + * • Icon: 24 dp; label: 11 sp Medium ([MrComicType.navLabel]). + * • Up to 5 destinations per Material 3 spec. + */ +@Composable +fun MrComicBottomBar( + destinations: List, + currentRoute: String?, + onDestinationClick: (MrComicBottomBarDestination) -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = MaterialTheme.colorScheme + Surface( + modifier = modifier.fillMaxWidth(), + color = colorScheme.surface, + contentColor = colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column { + HorizontalDivider( + thickness = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .height(BAR_HEIGHT) + .padding(horizontal = MrComicSpacingTokens.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + destinations.forEach { destination -> + MrComicBottomBarItem( + destination = destination, + selected = currentRoute == destination.route, + onClick = { onDestinationClick(destination) }, + ) + } + } + } + } +} + +/** A single bottom-bar entry. */ +data class MrComicBottomBarDestination( + val route: String, + val label: String, + val icon: ImageVector, + val contentDescription: String = label, +) + +@Composable +private fun RowScope.MrComicBottomBarItem( + destination: MrComicBottomBarDestination, + selected: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val contentColor = if (selected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant + + Box( + modifier = Modifier + .weight(1f) + .height(BAR_HEIGHT) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Box( + modifier = Modifier + .size(width = INDICATOR_WIDTH, height = INDICATOR_HEIGHT) + .background( + color = if (selected) { + colorScheme.primaryContainer + } else { + colorScheme.surface + }, + shape = RoundedCornerShape(MrComicCornerScale.lg), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = destination.icon, + contentDescription = destination.contentDescription, + modifier = Modifier.size(24.dp), + tint = contentColor, + ) + } + Text( + text = destination.label, + style = MrComicType.navLabel.copy( + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ), + color = contentColor, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + textAlign = TextAlign.Center, + ) + } + } +} + +private val BAR_HEIGHT: Dp = 64.dp +private val INDICATOR_WIDTH: Dp = 64.dp +private val INDICATOR_HEIGHT: Dp = 32.dp diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicCornerScale.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicCornerScale.kt new file mode 100644 index 000000000..6b894a1a1 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicCornerScale.kt @@ -0,0 +1,26 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Corner scale for the Editorial Ink design system. + * + * Use this for new components. `MrComicRadiusTokens` is the legacy scale used by + * existing screens; both scales coexist during migration. + * + * xs 4 dp — micro containers, dense pills + * sm 6 dp — inline controls, small chips + * md 10 dp — inputs, small cards, icon containers + * lg 14 dp — cards, list items, default container + * xl 20 dp — panels, hero surfaces, sheets + * pill 999 dp — fully rounded + */ +object MrComicCornerScale { + val xs: Dp = 4.dp + val sm: Dp = 6.dp + val md: Dp = 10.dp + val lg: Dp = 14.dp + val xl: Dp = 20.dp + val pill: Dp = 999.dp +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt index 8b850a359..a93f92133 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicDesignTokens.kt @@ -69,7 +69,9 @@ object MrComicColorTokens { const val InkPaperSurfaceContainerHighArgb = 0xFFE6DFD7L const val InkPaperSurfaceContainerHighestArgb = 0xFFE0D9D0L const val InkPaperOutlineArgb = 0xFF7C756AL - const val InkPaperCompletedArgb = 0xFF4CAF50L + // STYLE-PASTEL: soft sage instead of vivid Material green — calmer on cream + // paper backgrounds and slightly higher contrast for the completed badge. + const val InkPaperCompletedArgb = 0xFF5E9C67L const val InkPaperDarkPrimaryArgb = 0xFFAEC8EFL const val InkPaperDarkSecondaryArgb = 0xFFE6C79BL @@ -164,8 +166,11 @@ object MrComicThemePresetTokens { backgroundArgb = null, ) val neon = MrComicThemePresetToken( - primaryArgb = 0xFFE91E63L, - secondaryArgb = 0xFF00BCD4L, + // STYLE-PASTEL: "neon" re-tuned from hot magenta/cyan (0xFFE91E63 / + // 0xFF00BCD4) to gentle dusk pastels — soft lavender over soft teal. + // The preset keeps its id for backward compatibility with saved configs. + primaryArgb = 0xFFB79CEDL, + secondaryArgb = 0xFF93CFC4L, backgroundArgb = 0xFF0D0D1AL, ) val gray = MrComicThemePresetToken( diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicLibraryCard.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicLibraryCard.kt new file mode 100644 index 000000000..0f84d1157 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicLibraryCard.kt @@ -0,0 +1,216 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +/** Aspect ratio of the cover in a [MrComicLibraryCard]. */ +enum class MrComicLibraryCardAspect(val ratio: Float) { + /** 2:3 — default, used for books, comics, manga. */ + Portrait(2f / 3f), + + /** 16:9 — wide, used for landscape covers and banners. */ + Wide(16f / 9f), +} + +/** + * Editorial Ink library card. Cover image on top (2:3 by default), 1-2 lines + * of title and an optional meta line below. No drop shadow; the cover + * itself provides visual weight, the card frame uses [MrComicCornerScale.md] + * and a 0.5 dp hairline border. + */ +@Composable +fun MrComicLibraryCard( + coverUrl: String?, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + aspect: MrComicLibraryCardAspect = MrComicLibraryCardAspect.Portrait, + progress: Float? = null, + onClick: (() -> Unit)? = null, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val sanitizedProgress = progress?.coerceIn(0f, 1f) + + Column( + modifier = modifier + .then( + if (onClick != null) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + } + ), + verticalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x2), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspect.ratio) + .clip(RoundedCornerShape(MrComicCornerScale.md)) + .background(colorScheme.surfaceContainerHigh), + ) { + if (!coverUrl.isNullOrBlank()) { + AsyncImage( + model = coverUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + if (sanitizedProgress != null) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(2.dp) + .background(colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(sanitizedProgress) + .height(2.dp) + .background(colorScheme.primary), + ) + } + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.listTitle, + color = colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** + * Wide row variant of the library card. Cover on the left (72 dp square by + * default), title/subtitle on the right, optional trailing meta. Useful for + * continue-reading rows and search results. + */ +@Composable +fun MrComicLibraryCardRow( + coverUrl: String?, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + trailing: String? = null, + onClick: (() -> Unit)? = null, + coverSize: Dp = 72.dp, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + } + ) + .padding(vertical = MrComicSpacingTokens.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x3), + ) { + Surface( + modifier = Modifier.size(coverSize), + shape = RoundedCornerShape(MrComicCornerScale.md), + color = colorScheme.surfaceContainerHigh, + border = BorderStroke( + width = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ), + ) { + if (!coverUrl.isNullOrBlank()) { + AsyncImage( + model = coverUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(MrComicCornerScale.md)) + ) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.listTitle, + color = colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.bodySm, + color = colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (!trailing.isNullOrBlank()) { + Text( + text = trailing, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicListItem.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicListItem.kt new file mode 100644 index 000000000..192613d4b --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicListItem.kt @@ -0,0 +1,222 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** Leading slot for [MrComicListItem]. */ +sealed interface MrComicListItemLeading { + data class Icon( + val image: ImageVector, + val contentDescription: String? = null, + val tintContainer: Boolean = true, + ) : MrComicListItemLeading + + data class Custom(val content: @Composable () -> Unit) : MrComicListItemLeading +} + +/** Trailing slot for [MrComicListItem]. */ +sealed interface MrComicListItemTrailing { + data class Value(val text: String) : MrComicListItemTrailing + data class Chevron(val show: Boolean = true) : MrComicListItemTrailing + data class Switch( + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit, + val enabled: Boolean = true, + ) : MrComicListItemTrailing + data class Custom(val content: @Composable () -> Unit) : MrComicListItemTrailing +} + +/** + * Editorial Ink list item. Flat (no card background) with an optional hairline + * divider. Use this for settings rows, picker rows, and any list where each + * row belongs to a section rather than its own container. + * + * Sizing: 16 dp vertical padding, 20 dp horizontal padding (or screen padding + * via [modifier]). Leading icon container is 36 dp square with 10 dp radius. + * Title is 16 sp Medium ([MrComicType.listTitle]); subtitle is 14 sp Normal + * ([MrComicType.bodySm]). + */ +@Composable +fun MrComicListItem( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + leading: MrComicListItemLeading? = null, + trailing: MrComicListItemTrailing? = MrComicListItemTrailing.Chevron(), + onClick: (() -> Unit)? = null, + enabled: Boolean = true, + divider: Boolean = true, +) { + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val titleAlpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Subtle + val subtitleAlpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Hairline + + Surface( + modifier = modifier + .fillMaxWidth() + .then( + if (onClick != null && enabled) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + } + ), + color = Color.Transparent, + contentColor = colorScheme.onSurface, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MrComicSpacingTokens.x5, vertical = MrComicSpacingTokens.x4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x3), + ) { + if (leading != null) { + MrComicListItemLeading(leading, enabled) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.listTitle, + color = colorScheme.onSurface.copy(alpha = titleAlpha), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.bodySm, + color = colorScheme.onSurfaceVariant.copy(alpha = subtitleAlpha), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + MrComicListItemTrailing(trailing, enabled) + } + } + if (divider) { + HorizontalDivider( + modifier = Modifier.padding( + start = if (leading != null) MrComicSpacingTokens.x10 else MrComicSpacingTokens.x5, + ), + thickness = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ) + } + } + } +} + +@Composable +private fun MrComicListItemLeading(leading: MrComicListItemLeading, enabled: Boolean) { + when (leading) { + is MrComicListItemLeading.Icon -> { + val colorScheme = MaterialTheme.colorScheme + val containerColor = if (leading.tintContainer) { + colorScheme.primary.copy(alpha = MrComicAlphaTokens.Hairline) + } else { + Color.Transparent + } + val contentColor = colorScheme.primary + Box( + modifier = Modifier + .size(36.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(MrComicCornerScale.md), + color = containerColor, + contentColor = contentColor.copy(alpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Subtle), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = leading.image, + contentDescription = leading.contentDescription, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + is MrComicListItemLeading.Custom -> leading.content() + } +} + +@Composable +private fun MrComicListItemTrailing(trailing: MrComicListItemTrailing, enabled: Boolean) { + val colorScheme = MaterialTheme.colorScheme + when (trailing) { + is MrComicListItemTrailing.Value -> { + Surface( + shape = RoundedCornerShape(MrComicCornerScale.pill), + color = colorScheme.primary.copy(alpha = MrComicAlphaTokens.Hairline), + contentColor = colorScheme.primary, + ) { + Text( + text = trailing.text, + style = MrComicType.micro, + color = colorScheme.primary.copy( + alpha = if (enabled) MrComicAlphaTokens.Solid else MrComicAlphaTokens.Subtle, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } + is MrComicListItemTrailing.Chevron -> if (trailing.show) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = colorScheme.onSurfaceVariant.copy( + alpha = if (enabled) MrComicAlphaTokens.Subtle else MrComicAlphaTokens.Hairline, + ), + ) + } + is MrComicListItemTrailing.Switch -> { + Switch( + checked = trailing.checked, + onCheckedChange = trailing.onCheckedChange, + enabled = enabled && trailing.enabled, + ) + } + is MrComicListItemTrailing.Custom -> trailing.content() + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicPreviewBackdrop.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicPreviewBackdrop.kt new file mode 100644 index 000000000..2f90d7045 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicPreviewBackdrop.kt @@ -0,0 +1,64 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp + +/** + * Soft bed placed behind preview surfaces ("Предпросмотр …"). + * + * Preview panels often draw paper-like colors that match the surrounding + * settings background or the reader page behind a bottom sheet, so their + * edges visually merge with real content. The backdrop renders a slightly + * oversized, lightly blurred contrast bed plus a hairline border under the + * preview, keeping the preview surface readable on any background. + * + * Usage: wrap the preview composable's content — the backdrop matches the + * content size. + */ +@Composable +fun MrComicPreviewBackdrop( + modifier: Modifier = Modifier, + shape: Shape = MaterialTheme.shapes.large, + content: @Composable BoxScope.() -> Unit +) { + val bedColor = MaterialTheme.colorScheme.surfaceContainerHighest + val outlineColor = MaterialTheme.colorScheme.outlineVariant + Box(modifier = modifier) { + // Lightly blurred, slightly inflated contrast bed — the "light blur" + // that separates the preview from the page/text behind it. + Box( + modifier = Modifier + .matchParentSize() + .graphicsLayer { + scaleX = PREVIEW_BED_OVERSCALE + scaleY = PREVIEW_BED_OVERSCALE_TALL + alpha = PREVIEW_BED_ALPHA + } + .blur(PREVIEW_BED_BLUR, BlurredEdgeTreatment.Unbounded) + .background(bedColor, shape) + ) + Box( + modifier = Modifier + .matchParentSize() + .border(HairlineBorder, outlineColor.copy(alpha = HairlineBorderAlpha), shape) + ) + content() + } +} + +private const val PREVIEW_BED_OVERSCALE = 1.04f +private const val PREVIEW_BED_OVERSCALE_TALL = 1.06f +private const val PREVIEW_BED_ALPHA = 0.92f +private val PREVIEW_BED_BLUR = 12.dp +private val HairlineBorder = 1.dp +private const val HairlineBorderAlpha = 0.55f diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicSectionHeader.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicSectionHeader.kt new file mode 100644 index 000000000..6b9f0d5a6 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicSectionHeader.kt @@ -0,0 +1,65 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Section header for in-screen sections (Editorial Ink design system). + * + * Visual rhythm: 24 dp top padding / 8 dp bottom padding. Title is 24 sp + * SemiBold ([MrComicType.h2]), subtitle is 12 sp onSurfaceVariant ([MrComicType.meta]). + * An optional [trailing] slot hosts a "See all" link, count chip, or any + * other trailing action. + */ +@Composable +fun MrComicSectionHeader( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + trailing: (@Composable RowScope.() -> Unit)? = null, +) { + val colorScheme = MaterialTheme.colorScheme + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = MrComicSpacingTokens.x6, bottom = MrComicSpacingTokens.x2), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x3), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.h2, + color = colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (trailing != null) { + trailing() + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicTopAppBar.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicTopAppBar.kt new file mode 100644 index 000000000..69d0f2f3d --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicTopAppBar.kt @@ -0,0 +1,225 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Compact (64 dp) or expanded (96 dp with subtitle) variant of the top app bar. + */ +enum class MrComicTopAppBarVariant(val minHeight: Dp) { + Compact(64.dp), + Expanded(96.dp), +} + +/** A single top-bar action — either a direct icon button or an overflow menu item. */ +sealed interface MrComicTopAppBarAction { + val key: String + + data class Icon( + override val key: String, + val icon: ImageVector, + val contentDescription: String, + val tint: androidx.compose.ui.graphics.Color? = null, + val onClick: () -> Unit, + ) : MrComicTopAppBarAction + + data class Overflow( + override val key: String, + val label: String, + val onClick: () -> Unit, + ) : MrComicTopAppBarAction +} + +/** + * Editorial Ink top app bar. Single 64 dp (or 96 dp expanded) row with an + * optional tinted back button, a 24 sp title, a subtitle, and 1-3 direct + * icon actions with a trailing overflow menu. + * + * • Container: `surface` (no alpha tricks). + * • Border-bottom: 0.5 dp `outlineVariant` at [MrComicAlphaTokens.Hairline]. + * • Default action density: 4 dp between same-group icons, 16 dp between + * groups, single overflow at the end. + */ +@Composable +fun MrComicTopAppBar( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + onNavigateUp: (() -> Unit)? = null, + actions: List = emptyList(), + overflowActions: List = emptyList(), + variant: MrComicTopAppBarVariant = MrComicTopAppBarVariant.Compact, +) { + val colorScheme = MaterialTheme.colorScheme + val resolvedVariant = if (!subtitle.isNullOrBlank()) MrComicTopAppBarVariant.Expanded else variant + val (iconActions, remainingOverflow) = remember(actions, overflowActions) { + val direct = actions.filterIsInstance() + val inline = actions.filterIsInstance() + val effectiveOverflow = (inline + overflowActions).distinctBy { it.key } + direct to effectiveOverflow + } + + Surface( + modifier = modifier.fillMaxWidth(), + color = colorScheme.surface, + contentColor = colorScheme.onSurface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .heightIn(min = resolvedVariant.minHeight) + .padding(horizontal = MrComicSpacingTokens.x4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x2), + ) { + if (onNavigateUp != null) { + MrComicTopAppBarIconButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + onClick = onNavigateUp, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MrComicType.h1, + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank() && resolvedVariant == MrComicTopAppBarVariant.Expanded) { + Text( + text = subtitle, + style = MrComicType.meta, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + MrComicTopAppBarActions(iconActions, remainingOverflow) + } + HorizontalDivider( + thickness = 0.5.dp, + color = colorScheme.outlineVariant.copy(alpha = MrComicAlphaTokens.Hairline), + ) + } + } +} + +@Composable +private fun MrComicTopAppBarActions( + iconActions: List, + overflowActions: List, +) { + val visibleIcons = iconActions.take(MAX_INLINE_ICONS) + val hiddenIcons = iconActions.drop(MAX_INLINE_ICONS) + val colorScheme = MaterialTheme.colorScheme + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MrComicSpacingTokens.x1), + ) { + visibleIcons.forEach { action -> + MrComicTopAppBarIconButton( + icon = action.icon, + contentDescription = action.contentDescription, + tint = action.tint, + onClick = action.onClick, + ) + } + if (hiddenIcons.isNotEmpty() || overflowActions.isNotEmpty()) { + var menuExpanded by remember { mutableStateOf(false) } + Box { + MrComicTopAppBarIconButton( + icon = Icons.Default.MoreVert, + contentDescription = "More", + onClick = { menuExpanded = true }, + ) + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + shape = RoundedCornerShape(MrComicCornerScale.lg), + containerColor = colorScheme.surfaceContainer, + ) { + (hiddenIcons.map { icon -> + MrComicTopAppBarAction.Overflow( + key = icon.key, + label = icon.contentDescription, + onClick = icon.onClick, + ) + } + overflowActions).forEach { item -> + DropdownMenuItem( + text = { Text(item.label, style = MrComicType.body) }, + onClick = { + menuExpanded = false + item.onClick() + }, + ) + } + } + } + } + } +} + +@Composable +private fun MrComicTopAppBarIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + tint: androidx.compose.ui.graphics.Color? = null, +) { + val colorScheme = MaterialTheme.colorScheme + IconButton( + onClick = onClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint ?: colorScheme.onSurface, + ) + } +} + +private const val MAX_INLINE_ICONS = 2 diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicType.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicType.kt new file mode 100644 index 000000000..ef87f0ad7 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/MrComicType.kt @@ -0,0 +1,126 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +/** + * Editorial Ink type roles. + * + * New components should use these `TextStyle` constants directly instead of + * the Material 3 `MaterialTheme.typography.*` slots. The two systems coexist + * during migration. + * + * Roles are named for their semantic purpose (display, h1..h3, body, meta, + * micro) rather than visual size so that screen text and chrome text can + * evolve independently of the underlying scale. + */ +object MrComicType { + + // ── Display & Headings ──────────────────────────────────────────────── + + /** Hero / cover. 44 / 52, SemiBold 600, -0.015em. */ + val display: TextStyle = TextStyle( + fontSize = 44.sp, + lineHeight = 52.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.015).em, + ) + + /** Screen title (top app bar). 32 / 40, SemiBold 600, -0.01em. */ + val h1: TextStyle = TextStyle( + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.01).em, + ) + + /** Section header. 24 / 32, SemiBold 600, -0.005em. */ + val h2: TextStyle = TextStyle( + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.005).em, + ) + + /** Card / block title. 20 / 28, Medium 500, 0em. */ + val h3: TextStyle = TextStyle( + fontSize = 20.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Medium, + ) + + // ── Body ────────────────────────────────────────────────────────────── + + /** Default body. 16 / 26, Normal 400, 0em. */ + val body: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Normal, + ) + + /** Compact body. 14 / 22, Normal 400, 0em. */ + val bodySm: TextStyle = TextStyle( + fontSize = 14.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Normal, + ) + + // ── List item roles ─────────────────────────────────────────────────── + + /** List item title. 16 / 24, Medium 500, 0em. */ + val listTitle: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Medium, + ) + + /** List item subtitle (alias of [bodySm] for discoverability). */ + val listSubtitle: TextStyle = bodySm + + // ── Meta / micro ────────────────────────────────────────────────────── + + /** Meta label. 12 / 18, Medium 500, 0.01em. */ + val meta: TextStyle = TextStyle( + fontSize = 12.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.01.em, + ) + + /** Micro / badge. 11 / 16, Medium 500, 0.02em. */ + val micro: TextStyle = TextStyle( + fontSize = 11.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.02.em, + ) + + // ── Button labels ───────────────────────────────────────────────────── + + /** Default button label. 14 / 20, Medium 500, 0.01em. */ + val button: TextStyle = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.01.em, + ) + + /** Large / primary CTA label. 16 / 24, SemiBold 600, 0em. */ + val buttonLg: TextStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.SemiBold, + ) + + // ── Top bar / Bottom bar ────────────────────────────────────────────── + + /** Bottom navigation label. 11 / 16, Medium 500, 0.02em. */ + val navLabel: TextStyle = TextStyle( + fontSize = 11.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.02.em, + ) +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt.bak similarity index 100% rename from android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt rename to android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/Navigation.kt.bak diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt.bak similarity index 100% rename from android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt rename to android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/designsystem/SettingsRows.kt.bak diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt index baa186f3b..1ada21953 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/fonts/ReaderTextFontCatalog.kt @@ -28,6 +28,10 @@ object ReaderTextFontCatalog { "Roboto Slab" to "RobotoSlab-Regular.ttf", "PT Serif" to "PTSerif-Regular.ttf", "Literata" to "Literata-Regular.ttf", + "OpenDyslexic" to "OpenDyslexic-Regular.otf", + "Accessible DfA" to "AccessibleDfA.otf", + "iA Writer Duospace" to "iAWriterDuospace-Regular.ttf", + "Liberation Sans" to "LiberationSans-Regular.ttf", "Lora" to "Lora-Regular.ttf", "Source Serif 4" to "SourceSerif4-Regular.ttf" ) diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementCard.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementCard.kt new file mode 100644 index 000000000..35ad5acc3 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementCard.kt @@ -0,0 +1,263 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.Achievement +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.AchievementRarity +import io.leostrange.mrcomic.core.model.AchievementStatus + +/** + * Карточка достижения + */ +@Composable +fun AchievementCard( + achievement: Achievement, + progress: AchievementProgress, + modifier: Modifier = Modifier, + showProgress: Boolean = true +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.currentProgress, + animationSpec = tween(durationMillis = 500), + label = "achievement_progress" + ) + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = getAchievementBackgroundColor(progress.status) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Иконка достижения + AchievementIcon( + achievement = achievement, + status = progress.status, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация о достижении + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = achievement.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = achievement.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Статус + AchievementStatusBadge(status = progress.status) + } + + // Прогресс-бар + if (showProgress && progress.status != AchievementStatus.UNLOCKED) { + Spacer(modifier = Modifier.height(12.dp)) + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Прогресс", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${(animatedProgress * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = getAchievementColor(achievement.rarity), + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } + + // Награда XP + if (achievement.xpReward > 0) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "+${achievement.xpReward} XP", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +/** + * Иконка достижения + */ +@Composable +private fun AchievementIcon( + achievement: Achievement, + status: AchievementStatus, + modifier: Modifier = Modifier +) { + val backgroundColor = when (status) { + AchievementStatus.LOCKED -> MaterialTheme.colorScheme.surfaceVariant + AchievementStatus.UNLOCKED -> getAchievementColor(achievement.rarity) + AchievementStatus.CLAIMED -> getAchievementColor(achievement.rarity) + } + + Box( + modifier = modifier + .background( + color = backgroundColor, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = when (status) { + AchievementStatus.LOCKED -> Icons.Default.Lock + AchievementStatus.UNLOCKED -> Icons.Default.Star + AchievementStatus.CLAIMED -> Icons.Default.CheckCircle + }, + contentDescription = null, + tint = when (status) { + AchievementStatus.LOCKED -> MaterialTheme.colorScheme.onSurfaceVariant + AchievementStatus.UNLOCKED -> Color.White + AchievementStatus.CLAIMED -> Color.White + }, + modifier = Modifier.size(24.dp) + ) + } +} + +/** + * Бейдж статуса достижения + */ +@Composable +private fun AchievementStatusBadge( + status: AchievementStatus, + modifier: Modifier = Modifier +) { + val (text, color) = when (status) { + AchievementStatus.LOCKED -> "Заблокировано" to MaterialTheme.colorScheme.surfaceVariant + AchievementStatus.UNLOCKED -> "Разблокировано" to MaterialTheme.colorScheme.primary + AchievementStatus.CLAIMED -> "Получено" to MaterialTheme.colorScheme.tertiary + } + + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = color.copy(alpha = 0.1f) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } +} + +/** + * Получить цвет фона карточки в зависимости от статуса + */ +@Composable +private fun getAchievementBackgroundColor(status: AchievementStatus): Color { + return when (status) { + AchievementStatus.LOCKED -> MaterialTheme.colorScheme.surface + AchievementStatus.UNLOCKED -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + AchievementStatus.CLAIMED -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + } +} + +/** + * Получить цвет достижения в зависимости от редкости + */ +@Composable +private fun getAchievementColor(rarity: AchievementRarity): Color { + return when (rarity) { + AchievementRarity.COMMON -> MaterialTheme.colorScheme.primary + AchievementRarity.UNCOMMON -> MaterialTheme.colorScheme.secondary + AchievementRarity.RARE -> MaterialTheme.colorScheme.tertiary + AchievementRarity.EPIC -> MaterialTheme.colorScheme.error + AchievementRarity.LEGENDARY -> Color(0xFFFFD700) // Золотой + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementDetailScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementDetailScreen.kt new file mode 100644 index 000000000..253327978 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementDetailScreen.kt @@ -0,0 +1,343 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.Achievement +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.AchievementRarity +import io.leostrange.mrcomic.core.model.AchievementStatus + +/** + * Экран деталей достижения + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AchievementDetailScreen( + achievement: Achievement, + progress: AchievementProgress, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.currentProgress, + animationSpec = tween(durationMillis = 1000), + label = "detail_progress" + ) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Достижение", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Иконка достижения + Box( + modifier = Modifier + .size(120.dp) + .background( + color = getAchievementColor(achievement.rarity), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = when (progress.status) { + AchievementStatus.LOCKED -> Icons.Default.Lock + AchievementStatus.UNLOCKED -> Icons.Default.Star + AchievementStatus.CLAIMED -> Icons.Default.CheckCircle + }, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(64.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Название достижения + Text( + text = achievement.title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Описание + Text( + text = achievement.description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Прогресс + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Прогресс", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(6.dp)), + color = getAchievementColor(achievement.rarity), + trackColor = MaterialTheme.colorScheme.surface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "${(animatedProgress * 100).toInt()}%", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = getAchievementColor(achievement.rarity), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Информация + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + InfoRow( + label = "Категория", + value = getCategoryName(achievement.category.name) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + InfoRow( + label = "Редкость", + value = getRarityName(achievement.rarity) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + InfoRow( + label = "Награда", + value = "+${achievement.xpReward} XP" + ) + + if (progress.unlockedAt != null) { + val unlockedAt = progress.unlockedAt ?: 0L + Spacer(modifier = Modifier.height(8.dp)) + + InfoRow( + label = "Получено", + value = formatDate(unlockedAt) + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Статус + StatusBadge(status = progress.status) + } + } +} + +/** + * Строка информации + */ +@Composable +private fun InfoRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } +} + +/** + * Бейдж статуса + */ +@Composable +private fun StatusBadge( + status: AchievementStatus, + modifier: Modifier = Modifier +) { + val (text, color) = when (status) { + AchievementStatus.LOCKED -> "Заблокировано" to MaterialTheme.colorScheme.error + AchievementStatus.UNLOCKED -> "Разблокировано" to MaterialTheme.colorScheme.primary + AchievementStatus.CLAIMED -> "Получено" to MaterialTheme.colorScheme.tertiary + } + + Card( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.1f) + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) + ) + } +} + +/** + * Получить цвет достижения в зависимости от редкости + */ +@Composable +private fun getAchievementColor(rarity: AchievementRarity): Color { + return when (rarity) { + AchievementRarity.COMMON -> MaterialTheme.colorScheme.primary + AchievementRarity.UNCOMMON -> MaterialTheme.colorScheme.secondary + AchievementRarity.RARE -> MaterialTheme.colorScheme.tertiary + AchievementRarity.EPIC -> MaterialTheme.colorScheme.error + AchievementRarity.LEGENDARY -> Color(0xFFFFD700) // Золотой + } +} + +/** + * Получить название категории + */ +private fun getCategoryName(category: String): String { + return when (category) { + "READING" -> "Чтение" + "COLLECTION" -> "Коллекция" + "STREAK" -> "Серии" + "EXPLORATION" -> "Исследование" + "MILESTONE" -> "Вехи" + else -> category + } +} + +/** + * Получить название редкости + */ +private fun getRarityName(rarity: AchievementRarity): String { + return when (rarity) { + AchievementRarity.COMMON -> "Обычное" + AchievementRarity.UNCOMMON -> "Необычное" + AchievementRarity.RARE -> "Редкое" + AchievementRarity.EPIC -> "Эпическое" + AchievementRarity.LEGENDARY -> "Легендарное" + } +} + +/** + * Форматировать дату + */ +private fun formatDate(timestamp: Long): String { + val date = java.util.Date(timestamp) + val formatter = java.text.SimpleDateFormat("dd.MM.yyyy HH:mm", java.util.Locale.getDefault()) + return formatter.format(date) +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementListScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementListScreen.kt new file mode 100644 index 000000000..d5f460960 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementListScreen.kt @@ -0,0 +1,205 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.height +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.AchievementCategory +import io.leostrange.mrcomic.core.model.AchievementDefinitions +import io.leostrange.mrcomic.core.model.AchievementProgress +import io.leostrange.mrcomic.core.model.UserAchievements + +/** + * Экран списка достижений + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AchievementListScreen( + userAchievements: UserAchievements, + onBack: () -> Unit, + onAchievementClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Достижения", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Статистика + AchievementStats( + userAchievements = userAchievements, + modifier = Modifier.padding(16.dp) + ) + + // Список достижений по категориям + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AchievementCategory.entries.forEach { category -> + val achievements = AchievementDefinitions.getByCategory(category) + val progressMap = userAchievements.achievements.associateBy { it.achievementId } + + if (achievements.isNotEmpty()) { + item { + CategoryHeader(category = category) + } + + items(achievements) { achievement -> + val progress = progressMap[achievement.id] ?: AchievementProgress( + achievementId = achievement.id, + status = io.leostrange.mrcomic.core.model.AchievementStatus.LOCKED, + currentProgress = 0f + ) + + AchievementCard( + achievement = achievement, + progress = progress, + modifier = Modifier.fillMaxWidth(), + showProgress = true + ) + } + } + } + } + } + } +} + +/** + * Заголовок категории + */ +@Composable +private fun CategoryHeader( + category: AchievementCategory, + modifier: Modifier = Modifier +) { + val title = when (category) { + AchievementCategory.READING -> "Чтение" + AchievementCategory.COLLECTION -> "Коллекция" + AchievementCategory.STREAK -> "Серии" + AchievementCategory.EXPLORATION -> "Исследование" + AchievementCategory.MILESTONE -> "Вехи" + } + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(vertical = 8.dp) + ) +} + +/** + * Статистика достижений + */ +@Composable +private fun AchievementStats( + userAchievements: UserAchievements, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + Text( + text = "Общий прогресс", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(8.dp)) + + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + label = "Разблокировано", + value = "${userAchievements.unlockedCount}/${userAchievements.totalCount}" + ) + + StatItem( + label = "XP заработано", + value = "${userAchievements.totalXpEarned}" + ) + + StatItem( + label = "Процент", + value = "${(userAchievements.completionRate * 100).toInt()}%" + ) + } + } +} + +/** + * Элемент статистики + */ +@Composable +private fun StatItem( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + ) { + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementNotification.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementNotification.kt new file mode 100644 index 000000000..b5a7aad9c --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/AchievementNotification.kt @@ -0,0 +1,163 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.AchievementNotification +import kotlinx.coroutines.delay + +/** + * Уведомление о новом достижении + */ +@Composable +fun AchievementNotificationPopup( + notification: AchievementNotification, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + var visible by remember { mutableStateOf(true) } + + LaunchedEffect(notification) { + delay(3000) // Автоматическое скрытие через 3 секунды + visible = false + delay(300) // Время для анимации исчезновения + onDismiss() + } + + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Иконка достижения + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Новое достижение!", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = notification.achievement.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = notification.achievement.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (notification.xpEarned > 0) { + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "+${notification.xpEarned} XP", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + + // Кнопка закрытия + IconButton( + onClick = { + visible = false + onDismiss() + } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Закрыть", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +/** + * Контейнер для уведомлений о достижениях + */ +@Composable +fun AchievementNotificationContainer( + notifications: List, + onDismiss: (AchievementNotification) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + notifications.forEach { notification -> + AchievementNotificationPopup( + notification = notification, + onDismiss = { onDismiss(notification) } + ) + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/GamificationStatsScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/GamificationStatsScreen.kt new file mode 100644 index 000000000..f89d4c401 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/GamificationStatsScreen.kt @@ -0,0 +1,362 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.MascotProgressState +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState +import io.leostrange.mrcomic.core.model.UserAchievements + +/** + * Экран статистики геймификации + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GamificationStatsScreen( + userAchievements: UserAchievements, + mascotProgress: MascotProgressState, + goalState: DailyReadingGoalState, + onBack: () -> Unit, + onAchievementsClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Статистика", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Общая статистика + item { + OverallStatsCard( + userAchievements = userAchievements, + mascotProgress = mascotProgress + ) + } + + // Ежедневные цели + item { + DailyGoalCard(goalState = goalState) + } + + // Достижения + item { + AchievementsPreviewCard( + userAchievements = userAchievements, + onClick = onAchievementsClick + ) + } + + // Маскот + item { + MascotProgressCard(mascotProgress = mascotProgress) + } + } + } +} + +/** + * Карточка общей статистики + */ +@Composable +private fun OverallStatsCard( + userAchievements: UserAchievements, + mascotProgress: MascotProgressState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Общая статистика", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + icon = Icons.Default.MenuBook, + label = "Страниц", + value = "${mascotProgress.approxPagesRead}" + ) + + StatItem( + icon = Icons.Default.EmojiEvents, + label = "Тайтлов", + value = "${mascotProgress.completedTitles}" + ) + + StatItem( + icon = Icons.Default.Star, + label = "XP", + value = "${mascotProgress.xp}" + ) + } + } + } +} + +/** + * Карточка ежедневных целей + */ +@Composable +private fun DailyGoalCard( + goalState: DailyReadingGoalState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Ежедневные цели", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + icon = Icons.Default.MenuBook, + label = "Сегодня", + value = "${goalState.pagesReadToday}/${goalState.targetPages}" + ) + + StatItem( + icon = Icons.Default.LocalFireDepartment, + label = "Серия", + value = "${goalState.currentStreak} дней" + ) + + StatItem( + icon = Icons.Default.Timer, + label = "Неделя", + value = "${goalState.pagesReadThisWeek}/${goalState.weeklyTargetPages}" + ) + } + } + } +} + +/** + * Карточка превью достижений + */ +@Composable +private fun AchievementsPreviewCard( + userAchievements: UserAchievements, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ), + onClick = onClick + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Достижения", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = "${userAchievements.unlockedCount}/${userAchievements.totalCount}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Нажмите для просмотра всех достижений", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Карточка прогресса маскота + */ +@Composable +private fun MascotProgressCard( + mascotProgress: MascotProgressState, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Маскот", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatItem( + icon = Icons.Default.Star, + label = "Стадия", + value = getStageName(mascotProgress.stage) + ) + + StatItem( + icon = Icons.Default.MenuBook, + label = "Прогресс", + value = "${(mascotProgress.stageProgress * 100).toInt()}%" + ) + } + } + } +} + +/** + * Элемент статистики + */ +@Composable +private fun StatItem( + icon: ImageVector, + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Получить название стадии + */ +private fun getStageName(stage: io.leostrange.mrcomic.core.model.MascotStage): String { + return when (stage) { + io.leostrange.mrcomic.core.model.MascotStage.CHILD -> "Ребёнок" + io.leostrange.mrcomic.core.model.MascotStage.TEEN -> "Подросток" + io.leostrange.mrcomic.core.model.MascotStage.YOUNG -> "Юность" + io.leostrange.mrcomic.core.model.MascotStage.ADULT -> "Взрослый" + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/ReadingCharts.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/ReadingCharts.kt new file mode 100644 index 000000000..f9bfd8faa --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/ReadingCharts.kt @@ -0,0 +1,255 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingCalendarDay + +/** + * График чтения за неделю + */ +@Composable +fun WeeklyReadingChart( + recentActivity: List, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Активность за неделю", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (recentActivity.isEmpty()) { + Text( + text = "Нет данных", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + // График + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) { + val maxValue = recentActivity.maxOfOrNull { it.pagesRead }?.toFloat() ?: 1f + val width = size.width + val height = size.height + val stepX = width / (recentActivity.size - 1).coerceAtLeast(1) + + // Рисуем линию графика + val path = Path() + recentActivity.forEachIndexed { index, day -> + val x = index * stepX + val y = height - (day.pagesRead / maxValue) * height + + if (index == 0) { + path.moveTo(x, y) + } else { + path.lineTo(x, y) + } + } + + drawPath( + path = path, + color = Color(0xFF6200EE), + style = Stroke(width = 3.dp.toPx()) + ) + + // Рисуем точки + recentActivity.forEachIndexed { index, day -> + val x = index * stepX + val y = height - (day.pagesRead / maxValue) * height + + drawCircle( + color = Color(0xFF6200EE), + radius = 4.dp.toPx(), + center = Offset(x, y) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Подписи дней + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + recentActivity.forEach { day -> + Text( + text = getDayLabel(day.dayKey), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +/** + * График чтения за месяц + */ +@Composable +fun MonthlyReadingChart( + historyActivity: List, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Активность за месяц", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (historyActivity.isEmpty()) { + Text( + text = "Нет данных", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + // Тепловая карта + val weeks = historyActivity.chunked(7) + weeks.forEach { week -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + week.forEach { day -> + val intensity = when { + day.pagesRead == 0 -> 0.1f + day.pagesRead < 10 -> 0.3f + day.pagesRead < 30 -> 0.5f + day.pagesRead < 50 -> 0.7f + else -> 1.0f + } + + Canvas( + modifier = Modifier + .weight(1f) + .height(24.dp) + .padding(2.dp) + ) { + drawRoundRect( + color = Color(0xFF6200EE).copy(alpha = intensity), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(4.dp.toPx()) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Легенда + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.1f), label = "0") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.3f), label = "<10") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.5f), label = "<30") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 0.7f), label = "<50") + LegendItem(color = Color(0xFF6200EE).copy(alpha = 1.0f), label = "50+") + } + } + } + } +} + +/** + * Элемент легенды + */ +@Composable +private fun LegendItem( + color: Color, + label: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Canvas( + modifier = Modifier + .height(12.dp) + .padding(2.dp) + ) { + drawRoundRect( + color = color, + cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()) + ) + } + + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Получить метку дня + */ +private fun getDayLabel(dayKey: String): String { + return try { + val day = dayKey.takeLast(2).toInt() + when (day % 7) { + 0 -> "Вс" + 1 -> "Пн" + 2 -> "Вт" + 3 -> "Ср" + 4 -> "Чт" + 5 -> "Пт" + 6 -> "Сб" + else -> day.toString() + } + } catch (e: Exception) { + dayKey.takeLast(2) + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengeCard.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengeCard.kt new file mode 100644 index 000000000..6912ee5f8 --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengeCard.kt @@ -0,0 +1,210 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.WeeklyChallenge +import io.leostrange.mrcomic.core.model.WeeklyChallengeProgress +import io.leostrange.mrcomic.core.model.WeeklyChallengeStatus + +/** + * Карточка еженедельного челленджа + */ +@Composable +fun WeeklyChallengeCard( + challenge: WeeklyChallenge, + progress: WeeklyChallengeProgress, + modifier: Modifier = Modifier +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.progress, + animationSpec = tween(durationMillis = 500), + label = "challenge_progress" + ) + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = getChallengeBackgroundColor(progress.status) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Иконка + Icon( + imageVector = when (progress.status) { + WeeklyChallengeStatus.COMPLETED -> Icons.Default.CheckCircle + WeeklyChallengeStatus.ACTIVE -> Icons.Default.EmojiEvents + else -> Icons.Default.Timer + }, + contentDescription = null, + tint = getChallengeIconColor(progress.status), + modifier = Modifier.size(32.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = challenge.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = challenge.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Награда XP + if (challenge.xpReward > 0) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "+${challenge.xpReward}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "XP", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Прогресс + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Прогресс", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${progress.current}/${progress.target}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = getChallengeProgressColor(progress.status), + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + + // Статус + if (progress.status == WeeklyChallengeStatus.COMPLETED) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "✓ Завершено", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +/** + * Получить цвет фона карточки + */ +@Composable +private fun getChallengeBackgroundColor(status: WeeklyChallengeStatus): Color { + return when (status) { + WeeklyChallengeStatus.ACTIVE -> MaterialTheme.colorScheme.surface + WeeklyChallengeStatus.COMPLETED -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + WeeklyChallengeStatus.FAILED -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + WeeklyChallengeStatus.EXPIRED -> MaterialTheme.colorScheme.surfaceVariant + } +} + +/** + * Получить цвет иконки + */ +@Composable +private fun getChallengeIconColor(status: WeeklyChallengeStatus): Color { + return when (status) { + WeeklyChallengeStatus.ACTIVE -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.COMPLETED -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.FAILED -> MaterialTheme.colorScheme.error + WeeklyChallengeStatus.EXPIRED -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +/** + * Получить цвет прогресса + */ +@Composable +private fun getChallengeProgressColor(status: WeeklyChallengeStatus): Color { + return when (status) { + WeeklyChallengeStatus.ACTIVE -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.COMPLETED -> MaterialTheme.colorScheme.primary + WeeklyChallengeStatus.FAILED -> MaterialTheme.colorScheme.error + WeeklyChallengeStatus.EXPIRED -> MaterialTheme.colorScheme.onSurfaceVariant + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengesScreen.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengesScreen.kt new file mode 100644 index 000000000..0f5c57a0c --- /dev/null +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/gamification/WeeklyChallengesScreen.kt @@ -0,0 +1,99 @@ +package io.leostrange.mrcomic.core.ui.gamification + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.leostrange.mrcomic.core.model.WeeklyChallengeDefinitions +import io.leostrange.mrcomic.core.model.WeeklyChallengeProgress + +/** + * Экран еженедельных челленджей + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WeeklyChallengesScreen( + challengeProgress: List, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Еженедельные челленджи", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Назад" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Заголовок + Text( + text = "Выполните челленджи и получите XP", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // Список челленджей + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(WeeklyChallengeDefinitions.challenges) { challenge -> + val progress = challengeProgress.find { it.challengeId == challenge.id } + ?: WeeklyChallengeProgress( + challengeId = challenge.id, + current = 0, + target = challenge.target, + status = io.leostrange.mrcomic.core.model.WeeklyChallengeStatus.ACTIVE + ) + + WeeklyChallengeCard( + challenge = challenge, + progress = progress, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt index 1a8217501..4f49dd953 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryBackdropLayers.kt @@ -549,6 +549,34 @@ fun LibraryBackdropLayer( Box(modifier = modifier.fillMaxSize().background(spec.baseColor)) + // AMBIENT-WASH: three soft scheme-tinted radial blobs give every backdrop + // style a gentle sense of depth and tie the background to the active theme + // palette without touching any individual style spec. Skipped for IMAGE + // (user photo already carries color) and reduced-visual-effects mode. + if (!performanceHints.reducedVisualEffects && normalizedStyle != "IMAGE") { + Canvas(modifier = modifier.fillMaxSize()) { + val dim = maxOf(size.width, size.height) + val washAlpha = (0.05f + effectiveBackdropStrength.coerceIn(0f, 1f) * 0.15f) * + when (variant) { + LibraryBackdropVariant.LIGHT -> 1.00f + LibraryBackdropVariant.DARK -> 0.78f + LibraryBackdropVariant.AMOLED -> 0.38f + } + fun drawTintBlob(color: Color, alpha: Float, cx: Float, cy: Float, radiusScale: Float) { + drawRect( + brush = Brush.radialGradient( + colors = listOf(color.copy(alpha = alpha), Color.Transparent), + center = Offset(size.width * cx, size.height * cy), + radius = dim * radiusScale + ) + ) + } + drawTintBlob(colorScheme.primary, washAlpha, 0.14f, 0.10f, 0.85f) + drawTintBlob(colorScheme.secondary, washAlpha * 0.90f, 0.90f, 0.34f, 0.75f) + drawTintBlob(colorScheme.tertiary, washAlpha * 0.70f, 0.42f, 0.98f, 0.90f) + } + } + if (normalizedStyle == "IMAGE" && !backgroundImageUri.isNullOrBlank()) { AsyncImage( model = Uri.parse(backgroundImageUri), diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt index 080490264..113ec8f00 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/library/LibraryVisualSpecs.kt @@ -108,7 +108,7 @@ const val DEFAULT_LIBRARY_GRAPHIC_COVER_STYLE = MrComicLibraryStyleTokens.CoverM const val DEFAULT_LIBRARY_CARD_STYLE = "BALANCED" const val DEFAULT_LIBRARY_THUMBNAIL_MODE = "RECTANGLE" const val DEFAULT_LIBRARY_COVER_SCALE = "CROP" -const val DEFAULT_LIBRARY_BACKDROP_STRENGTH = 0.22f +const val DEFAULT_LIBRARY_BACKDROP_STRENGTH = 0.42f const val DEFAULT_LIBRARY_BACKGROUND_BLUR = 0.16f const val DEFAULT_LIBRARY_BACKGROUND_VEIL = 0.12f const val DEFAULT_LIBRARY_SHELF_DEPTH = 0.42f @@ -124,10 +124,12 @@ private fun resolveLibraryBackdropIntensity( variant: LibraryBackdropVariant, style: String ): Float { + // STYLE-BACKDROP: raised across the board — the previous values rendered the + // styled backdrops nearly invisible (≈0.03 alpha), which read as flat gray. val variantScale = when (variant) { - LibraryBackdropVariant.LIGHT -> 0.72f - LibraryBackdropVariant.DARK -> 0.52f - LibraryBackdropVariant.AMOLED -> 0.28f + LibraryBackdropVariant.LIGHT -> 0.92f + LibraryBackdropVariant.DARK -> 0.66f + LibraryBackdropVariant.AMOLED -> 0.34f } val styleScale = when (style) { "CITY_LIBRARY" -> 0.74f @@ -140,7 +142,7 @@ private fun resolveLibraryBackdropIntensity( "IMAGE" -> 0.64f else -> 0.88f } - return (0.022f + backdropStrength.coerceIn(0f, 1f) * 0.11f) * variantScale * styleScale + return (0.032f + backdropStrength.coerceIn(0f, 1f) * 0.17f) * variantScale * styleScale } internal fun resolveLibraryDetailIntensity( diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt index 4f27f0f34..c2e116db5 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStrings.kt @@ -24,22 +24,6 @@ data class AppStrings( val actionSquare: String, val actionFile: String, val actionFolder: String, - val opdsCatalog: String, - val opdsCatalogs: String, - val opdsSearch: String, - val opdsSearchPlaceholder: String, - val opdsCatalogPickerTitle: String, - val opdsCategories: String, - val opdsBooks: String, - val opdsLoadMore: String, - val opdsDownload: String, - val opdsRetry: String, - val opdsProjectGutenberg: String, - val opdsProjectGutenbergDescription: String, - val opdsFeedbooks: String, - val opdsFeedbooksDescription: String, - val opdsManyBooks: String, - val opdsManyBooksDescription: String, val readerPages: String, val readerBookmark: String, val readerBookmarked: String, diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt index 59f12f906..08680a3f3 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsEn.kt @@ -20,22 +20,6 @@ val English = AppStrings( actionSquare = "Square", actionFile = "File", actionFolder = "Folder", - opdsCatalog = "Online catalogs", - opdsCatalogs = "Online catalogs", - opdsSearch = "Search", - opdsSearchPlaceholder = "Search…", - opdsCatalogPickerTitle = "Book catalogs", - opdsCategories = "Categories", - opdsBooks = "Books", - opdsLoadMore = "Load more", - opdsDownload = "Download", - opdsRetry = "Retry", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "Free eBooks (public domain)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "Public domain books", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "Free eBooks collection", readerPages = "Pages", readerBookmark = "Bookmark", readerBookmarked = "Bookmarked", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt index f9e3399d3..644a74175 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsJa.kt @@ -20,22 +20,6 @@ val Japanese = AppStrings( actionSquare = "正方形", actionFile = "ファイル", actionFolder = "フォルダ", - opdsCatalog = "オンラインカタログ", - opdsCatalogs = "オンラインカタログ", - opdsSearch = "検索", - opdsSearchPlaceholder = "検索…", - opdsCatalogPickerTitle = "ブックカタログ", - opdsCategories = "カテゴリ", - opdsBooks = "書籍", - opdsLoadMore = "さらに読み込む", - opdsDownload = "ダウンロード", - opdsRetry = "再試行", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "無料の電子書籍(パブリックドメイン)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "パブリックドメインの書籍", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "無料電子書籍コレクション", readerPages = "ページ", readerBookmark = "しおり", readerBookmarked = "しおり済み", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt index fee4e3d94..d8a9175b5 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsKo.kt @@ -20,22 +20,6 @@ val Korean = AppStrings( actionSquare = "정사각형", actionFile = "파일", actionFolder = "폴더", - opdsCatalog = "온라인 카탈로그", - opdsCatalogs = "온라인 카탈로그", - opdsSearch = "검색", - opdsSearchPlaceholder = "검색…", - opdsCatalogPickerTitle = "도서 카탈로그", - opdsCategories = "카테고리", - opdsBooks = "도서", - opdsLoadMore = "더 불러오기", - opdsDownload = "다운로드", - opdsRetry = "다시 시도", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "무료 전자책(퍼블릭 도메인)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "퍼블릭 도메인 도서", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "무료 전자책 모음", readerPages = "페이지", readerBookmark = "북마크", readerBookmarked = "북마크됨", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt index 0aad4f664..413341422 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsRu.kt @@ -20,22 +20,6 @@ val Russian = AppStrings( actionSquare = "Квадрат", actionFile = "Файл", actionFolder = "Папка", - opdsCatalog = "Онлайн-каталоги", - opdsCatalogs = "Онлайн-каталоги", - opdsSearch = "Поиск", - opdsSearchPlaceholder = "Поиск…", - opdsCatalogPickerTitle = "Каталоги книг", - opdsCategories = "Категории", - opdsBooks = "Книги", - opdsLoadMore = "Загрузить ещё", - opdsDownload = "Скачать", - opdsRetry = "Повторить", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "Бесплатные электронные книги (общественное достояние)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "Книги общественного достояния", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "Коллекция бесплатных электронных книг", readerPages = "Страницы", readerBookmark = "Закладка", readerBookmarked = "В закладках", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt index 374815c6b..1d907ee30 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/locale/AppStringsZh.kt @@ -20,22 +20,6 @@ val ChineseSimplified = AppStrings( actionSquare = "方形", actionFile = "文件", actionFolder = "文件夹", - opdsCatalog = "在线目录", - opdsCatalogs = "在线目录", - opdsSearch = "搜索", - opdsSearchPlaceholder = "搜索…", - opdsCatalogPickerTitle = "书籍目录", - opdsCategories = "分类", - opdsBooks = "书籍", - opdsLoadMore = "加载更多", - opdsDownload = "下载", - opdsRetry = "重试", - opdsProjectGutenberg = "Project Gutenberg", - opdsProjectGutenbergDescription = "免费电子书(公版)", - opdsFeedbooks = "Feedbooks", - opdsFeedbooksDescription = "公版书籍", - opdsManyBooks = "ManyBooks", - opdsManyBooksDescription = "免费电子书合集", readerPages = "分页", readerBookmark = "书签", readerBookmarked = "已加书签", diff --git a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt index c007fa409..cb4a0a139 100644 --- a/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt +++ b/android/core-ui/src/main/java/io/leostrange/mrcomic/core/ui/theme/Theme.kt @@ -2,8 +2,8 @@ package io.leostrange.mrcomic.core.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme @@ -11,6 +11,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance @@ -247,10 +248,8 @@ fun MrComicTheme( val context = LocalContext.current val systemDark = isSystemInDarkTheme() + @Suppress("NAME_SHADOWING") val colorScheme = if (isEInk) { - // On e-ink: always use the high-contrast grayscale scheme. - // Ignore user theme settings — e-ink panels are typically monochrome - // or have a very limited color gamut where Material colors are meaningless. EInkColorScheme } else { val darkTheme = when (themeConfig.themeMode) { diff --git a/android/core-ui/src/test/java/io/leostrange/mrcomic/core/ui/designsystem/EditorialInkTokensTest.kt b/android/core-ui/src/test/java/io/leostrange/mrcomic/core/ui/designsystem/EditorialInkTokensTest.kt new file mode 100644 index 000000000..994867337 --- /dev/null +++ b/android/core-ui/src/test/java/io/leostrange/mrcomic/core/ui/designsystem/EditorialInkTokensTest.kt @@ -0,0 +1,143 @@ +package io.leostrange.mrcomic.core.ui.designsystem + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Token contract tests for the Editorial Ink design system. + * + * These tests pin the values of new tokens so accidental refactors are caught + * by the build. They run on the JVM (no Compose runtime needed) because + * `TextUnit` and `Dp` are plain data classes. + */ +class EditorialInkTokensTest { + + // ── Alpha ───────────────────────────────────────────────────────────── + + @Test + fun alphaTokensAreOrderedByOpacity() { + assertTrue( + "Subtle must be more transparent than Soft", + MrComicAlphaTokens.Subtle < MrComicAlphaTokens.Soft, + ) + assertTrue( + "Soft must be more transparent than Solid", + MrComicAlphaTokens.Soft < MrComicAlphaTokens.Solid, + ) + assertTrue( + "Hairline must be the most transparent", + MrComicAlphaTokens.Hairline < MrComicAlphaTokens.Subtle, + ) + assertEquals(1.0f, MrComicAlphaTokens.Solid, 0.0f) + } + + // ── Corner scale ────────────────────────────────────────────────────── + + @Test + fun cornerScaleIsMonotonic() { + val radii = listOf( + MrComicCornerScale.xs, + MrComicCornerScale.sm, + MrComicCornerScale.md, + MrComicCornerScale.lg, + MrComicCornerScale.xl, + ) + for (i in 1 until radii.size) { + assertTrue( + "Corner scale must be strictly increasing: ${radii[i - 1]} < ${radii[i]}", + radii[i - 1] < radii[i], + ) + } + } + + @Test + fun cornerScaleHasExpectedStepValues() { + assertEquals(4.dp, MrComicCornerScale.xs) + assertEquals(6.dp, MrComicCornerScale.sm) + assertEquals(10.dp, MrComicCornerScale.md) + assertEquals(14.dp, MrComicCornerScale.lg) + assertEquals(20.dp, MrComicCornerScale.xl) + assertEquals(999.dp, MrComicCornerScale.pill) + } + + // ── Type scale ──────────────────────────────────────────────────────── + + @Test + fun typeRolesHaveNonEmptyStyles() { + // Sanity: each role exists and has a positive font size and line height. + val roles = mapOf( + "display" to MrComicType.display, + "h1" to MrComicType.h1, + "h2" to MrComicType.h2, + "h3" to MrComicType.h3, + "body" to MrComicType.body, + "bodySm" to MrComicType.bodySm, + "listTitle" to MrComicType.listTitle, + "listSubtitle" to MrComicType.listSubtitle, + "meta" to MrComicType.meta, + "micro" to MrComicType.micro, + "button" to MrComicType.button, + "buttonLg" to MrComicType.buttonLg, + "navLabel" to MrComicType.navLabel, + ) + roles.forEach { (name, style) -> + assertNotNull("$name style must not be null", style) + val fontSize: TextUnit = style.fontSize + val lineHeight: TextUnit = style.lineHeight + assertTrue( + "$name font size must be > 0 (got $fontSize)", + fontSize.value > 0f, + ) + assertTrue( + "$name line height must be >= font size (got lineHeight=$lineHeight, fontSize=$fontSize)", + lineHeight.value >= fontSize.value, + ) + } + } + + @Test + fun typeScaleIsMonotonicByFontSize() { + // The display…bodySm cascade is strictly increasing in size. + val sizes = listOf( + MrComicType.micro.fontSize, + MrComicType.meta.fontSize, + MrComicType.bodySm.fontSize, + MrComicType.body.fontSize, + MrComicType.h3.fontSize, + MrComicType.h2.fontSize, + MrComicType.h1.fontSize, + MrComicType.display.fontSize, + ) + for (i in 1 until sizes.size) { + assertTrue( + "Type scale must be strictly increasing: ${sizes[i - 1]} < ${sizes[i]}", + sizes[i - 1] < sizes[i], + ) + } + } + + @Test + fun badgeMicroMeetsMinimumReadableSize() { + // Micro replaces the legacy 9 sp badge token — must be at least 11 sp. + assertTrue( + "Micro font size must be >= 11 sp (got ${MrComicType.micro.fontSize})", + MrComicType.micro.fontSize.value >= 11f, + ) + } + + @Test + fun listItemTitleUsesSixteenSp() { + // List item title is 16 sp Medium — verify it stayed at body size for density. + assertEquals(16.sp, MrComicType.listTitle.fontSize) + } +} + +private fun Dp.assertEqualDp(other: Dp) { + assertEquals(this.value, other.value, 0.0f) +} diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt index 973032aa8..f53183636 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/epub/EpubTocResolver.kt @@ -103,7 +103,10 @@ internal class EpubTocResolver( return result.sortedBy { it.order }.mapNotNull { nav -> if (EpubFootnoteResolver.isFootnoteTocEntry(nav.src, nav.title)) return@mapNotNull null val href = try { URLDecoder.decode(nav.src, "UTF-8") } catch (_: Exception) { nav.src } - srcToPageIndex(href, ncxDir, fallbackBaseDir = opfDir)?.let { TocEntry(nav.title, it) } + val anchorId = href.substringAfter('#', "").takeIf { it.isNotBlank() } + srcToPageIndex(href, ncxDir, fallbackBaseDir = opfDir)?.let { + TocEntry(nav.title, it, anchorId = anchorId, sectionIndex = it) + } } } @@ -126,8 +129,9 @@ internal class EpubTocResolver( val title = CHUNK_HTML_TAG_RE.replace(match.groupValues[2], "").trim() if (title.isEmpty()) continue if (EpubFootnoteResolver.isFootnoteTocEntry(href, title)) continue + val anchorId = href.substringAfter('#', "").takeIf { it.isNotBlank() } val pageIdx = srcToPageIndex(href, navDir, fallbackBaseDir = opfDir) ?: continue - result.add(TocEntry(title, pageIdx)) + result.add(TocEntry(title, pageIdx, anchorId = anchorId, sectionIndex = pageIdx)) } return result } diff --git a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt index ee2a03c6b..19b327977 100644 --- a/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt +++ b/android/engine-formats/src/main/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FormatReader.kt @@ -52,6 +52,10 @@ class Fb2FormatReader( """<(?:h2|h3|p|blockquote)\b[^>]*>.*?||
)?""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) ) + private val SEMANTIC_PAGE_START_RE = Regex( + """^\s*(?:]*mrcomic-cover-image)""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) + ) private const val FB2_READER_CSS = """ a.fn,a[href*="FbAutId_"],a[href^="fbanchor://"]{font-size:0.75em;vertical-align:super;line-height:1; font-weight:bold;text-decoration:none;cursor:pointer} @@ -605,7 +609,7 @@ p.note-item{margin:0.6em 0;padding-left:2.8em;text-indent:-2.8em;text-align:left val tocEntries = tocRaw.mapNotNull { (title, rawSectionIdx) -> val clampedIdx = rawSectionIdx.coerceAtMost((rawSections.size - 1).coerceAtLeast(0)) val pageIdx = rawToMergedPage[clampedIdx] ?: 0 - TocEntry(title, pageIdx) + TocEntry(title, pageIdx, sectionIndex = pageIdx) }.toMutableList() val anchorPageMap = sectionRawStarts.mapValues { (_, rawSectionIdx) -> val clampedIdx = rawSectionIdx.coerceAtMost((rawSections.size - 1).coerceAtLeast(0)) @@ -672,8 +676,9 @@ p.note-item{margin:0.6em 0;padding-left:2.8em;text-indent:-2.8em;text-align:left /** * Groups consecutive short raw sections (e.g. individual footnotes) into reader pages * until the combined text reaches [CHARS_PER_PAGE], and maps every raw section index - * to its merged reader page. Sections flagged as explicit section starts always begin - * a new merged page so front-matter (cover/TOC) stays separate from chapter text. + * to its merged reader page. A top-level section starts a new page only when it has + * semantic page content (a title or cover). Untitled FB2 fragments are allowed to flow + * together, avoiding pages with only a few lines while preserving real chapter breaks. */ private fun mergeRawSections( rawSections: List, @@ -686,7 +691,9 @@ p.note-item{margin:0.6em 0;padding-left:2.8em;text-indent:-2.8em;text-align:left var pendingChars = 0 for ((rawIdx, section) in rawSections.withIndex()) { val sectionChars = HTML_TAG_RE.replace(section, "").length - if (pendingChars > 0 && rawSectionStarts.getOrNull(rawIdx) == true) { + val isSemanticSectionStart = rawSectionStarts.getOrNull(rawIdx) == true && + SEMANTIC_PAGE_START_RE.containsMatchIn(section) + if (pendingChars > 0 && isSemanticSectionStart) { mergedSections.add(pendingMerge.toString()) pendingMerge.clear() pendingChars = 0 diff --git a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt index 4af30e04a..767a7abcf 100644 --- a/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt +++ b/android/engine-formats/src/test/kotlin/io/leostrange/mrcomic/engine/formats/fb2/Fb2FrontMatterTest.kt @@ -15,6 +15,44 @@ import java.io.File @Config(sdk = [35]) class Fb2FrontMatterTest { + @Test + fun untitledTopLevelFragmentsDoNotCreateAlmostEmptyPages() = runBlocking { + val sample = File.createTempFile("mrcomic-untitled-fragments", ".fb2") + sample.writeText( + """ + + + Fragmentsru + +

Короткий остаток первой части.

+

Продолжение того же текста без заголовка.

+
+ <p>Глава вторая</p> +

Новая глава должна начинаться отдельно.

+
+ +
+ """.trimIndent() + ) + + val reader = Fb2FormatReader(ContextWrapper(null), sample.absolutePath) + try { + val pages = (0 until reader.getPageCount()).mapNotNull { reader.getHtmlPage(it) } + + assertEquals("Untitled fragments should share one reader page", 2, pages.size) + assertTrue(pages[0].contains("Короткий остаток")) + assertTrue(pages[0].contains("Продолжение того же текста")) + assertFalse(pages[0].contains("Глава вторая")) + assertTrue(pages[1].contains("Глава вторая")) + assertEquals(0, reader.resolveHrefToPage("#fragment-a")) + assertEquals(0, reader.resolveHrefToPage("#fragment-b")) + assertEquals(1, reader.resolveHrefToPage("#chapter-two")) + } finally { + reader.close() + sample.delete() + } + } + @Test fun coverSynopsisTocAndFirstChapterRemainSeparateAndLinked() = runBlocking { val sample = File.createTempFile("mrcomic-front-matter", ".fb2") diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt index 198a31420..2ee041362 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/AudiobookPlayerScreen.kt @@ -30,8 +30,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.MenuBook import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -47,8 +45,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -73,7 +69,10 @@ import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButtonVariant import io.leostrange.mrcomic.core.ui.designsystem.MrComicPill import io.leostrange.mrcomic.core.ui.designsystem.MrComicSlider -import io.leostrange.mrcomic.core.ui.library.RootChromeTopBarHost +import io.leostrange.mrcomic.core.ui.designsystem.MrComicTopAppBar +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCover +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCoverKind import coil.compose.AsyncImage @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @@ -102,29 +101,10 @@ fun AudiobookPlayerScreen( containerColor = MaterialTheme.colorScheme.background, contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { - RootChromeTopBarHost { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ), - windowInsets = WindowInsets(0, 0, 0, 0), - title = { - Text( - text = audiobook?.title ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { - MrComicIconButton( - onClick = onNavigateBack, - variant = MrComicIconButtonVariant.Tonal - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад") - } - } - ) - } + MrComicTopAppBar( + title = audiobook?.title.orEmpty(), + onNavigateUp = onNavigateBack + ) } ) { padding -> Column( @@ -140,10 +120,19 @@ fun AudiobookPlayerScreen( .fillMaxWidth() .aspectRatio(1f) .heightIn(max = 180.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surface), + .clip(RoundedCornerShape(16.dp)), contentAlignment = Alignment.Center ) { + val playerFallbackKind = if (audiobook?.sourceIsFolder == true) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook?.title.orEmpty(), + kind = playerFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook?.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -151,21 +140,13 @@ fun AudiobookPlayerScreen( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - } else { - Icon( - imageVector = Icons.Default.Headphones, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) } } Text( text = audiobook?.title ?: "", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, + style = MrComicType.h3, + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 24.dp) ) @@ -174,7 +155,7 @@ fun AudiobookPlayerScreen( val chapter = audiobook.chapters.getOrNull(uiState.currentChapterIndex) Text( text = chapter?.title ?: "Глава ${uiState.currentChapterIndex + 1}", - style = MaterialTheme.typography.bodySmall, + style = MrComicType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -203,12 +184,12 @@ fun AudiobookPlayerScreen( ) { Text( text = uiState.positionMs.toTimeString(), - style = MaterialTheme.typography.labelSmall, + style = MrComicType.meta, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = uiState.durationMs.toTimeString(), - style = MaterialTheme.typography.labelSmall, + style = MrComicType.meta, color = MaterialTheme.colorScheme.onSurfaceVariant ) } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt index 805e6d506..57e052fec 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryComicInfoSheet.kt @@ -47,6 +47,7 @@ import coil.compose.AsyncImage import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ComicLibraryShelf import io.leostrange.mrcomic.core.model.ComicReadingStatus +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.model.libraryShelfCategory import io.leostrange.mrcomic.core.model.readingStatus import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip @@ -195,13 +196,13 @@ internal fun ComicInfoSheet( strings.libraryProgressTemplate.format( comic.currentPage + 1, comic.pageCount, - (comic.readingProgress * 100).toInt() + (comic.displayReadingProgress() * 100).toInt() ), style = MaterialTheme.typography.bodySmall ) } MrComicProgressLine( - progress = { comic.readingProgress }, + progress = { comic.displayReadingProgress() }, modifier = Modifier.fillMaxWidth() ) } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt index 2b0b75ca4..0eeb414d5 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryEmptyStates.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.CheckCircle @@ -62,23 +61,16 @@ internal fun EmptyLibraryPlaceholder( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(24.dp)) - MrComicButton( - onClick = onAddFile, - variant = MrComicButtonVariant.Filled - ) { - Icon(Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(strings.libraryOpenFile) - } - Spacer(Modifier.height(8.dp)) - MrComicButton( - onClick = onAddFolder, - variant = MrComicButtonVariant.Outlined - ) { - Icon(Icons.Default.FolderOpen, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(strings.libraryOpenFolder) - } + Text( + text = if (strings.languageCode == "en") { + "Use the top menu to add a file or folder." + } else { + "Файлы и папки добавляются через верхнее меню." + }, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt index 6dfb3210b..52bfbe618 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryFolders.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* @@ -37,6 +36,8 @@ import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.library.components.CoverArt import io.leostrange.mrcomic.feature.library.components.FolderBackgroundStack import io.leostrange.mrcomic.feature.library.components.FolderCoverTreatment +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCover +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCoverKind import io.leostrange.mrcomic.feature.library.components.libraryGridCoverRatio @Composable @@ -341,13 +342,27 @@ internal fun FolderCover( modifier = Modifier.fillMaxSize() .let { if (hasCover) it.clip(RoundedCornerShape(12.dp)) else it } ) - FolderCoverTreatment( - title = title, - hasCover = hasCover, - fileCount = fileCount, - subfolderCount = subfolderCount, - modifier = Modifier.fillMaxSize() - ) + // Cover the FolderOpen icon treatment with the new fallback when there + // is no cover, so folder cards without artwork still get a distinct + // gradient/monogram instead of a single flat icon. + if (!hasCover) { + LibraryFallbackCover( + title = title, + kind = LibraryFallbackCoverKind.FOLDER, + shape = RoundedCornerShape(12.dp), + showIcon = false, + modifier = Modifier.fillMaxSize(), + ) + } + if (hasCover) { + FolderCoverTreatment( + title = title, + hasCover = true, + fileCount = fileCount, + subfolderCount = subfolderCount, + modifier = Modifier.fillMaxSize() + ) + } if (showTitleOverlay) { Box( modifier = Modifier @@ -479,10 +494,19 @@ internal fun AudiobookGridItem( Box( modifier = Modifier .size(thumbSize.first, thumbSize.second) - .clip(RoundedCornerShape((radiusBase * 0.52f).coerceAtLeast(4.dp))) - .background(MaterialTheme.colorScheme.surfaceVariant), + .clip(RoundedCornerShape((radiusBase * 0.52f).coerceAtLeast(4.dp))), contentAlignment = Alignment.Center ) { + val listFallbackKind = if (audiobook.sourceIsFolder) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook.title, + kind = listFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -490,17 +514,6 @@ internal fun AudiobookGridItem( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - } else { - Icon( - Icons.Default.Headphones, - contentDescription = null, - // Scale icon proportionally to the thumb container (~45% of shorter side). - modifier = Modifier.size( - minOf(thumbSize.first.value, thumbSize.second.value) - .times(0.45f).coerceIn(20f, 40f).dp - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.56f) - ) } } Column( @@ -530,7 +543,7 @@ internal fun AudiobookGridItem( ) { Icon( Icons.Default.PlayArrow, - contentDescription = "Воспроизвести", + contentDescription = null, modifier = Modifier.padding(8.dp).size(20.dp), tint = MaterialTheme.colorScheme.primary ) @@ -550,10 +563,19 @@ internal fun AudiobookGridItem( Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(coverRatio) - .background(MaterialTheme.colorScheme.surfaceVariant), + .aspectRatio(coverRatio), contentAlignment = Alignment.Center ) { + val gridFallbackKind = if (audiobook.sourceIsFolder) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook.title, + kind = gridFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -561,14 +583,6 @@ internal fun AudiobookGridItem( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - } else { - Icon( - Icons.Default.Headphones, - contentDescription = null, - // Scale icon relative to the cover Box so it matches other grid items. - modifier = Modifier.fillMaxSize(0.38f), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - ) } if (showCoverTitles) { Surface( @@ -611,7 +625,7 @@ internal fun AudiobookGridItem( ) { Icon( Icons.Default.PlayArrow, - contentDescription = "Воспроизвести", + contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onPrimary ) diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt index 2475cb532..ae0045380 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreen.kt @@ -40,6 +40,7 @@ import io.leostrange.mrcomic.core.domain.analytics.resolveMrComicMascotState import io.leostrange.mrcomic.core.data.db.entity.SavedQuote import io.leostrange.mrcomic.core.model.Audiobook import io.leostrange.mrcomic.core.model.isReadCompleted +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSpacingTokens import io.leostrange.mrcomic.core.ui.locale.LocalStrings import io.leostrange.mrcomic.core.ui.locale.libraryQuoteSourceMissingLabel import io.leostrange.mrcomic.feature.library.components.AchievementQuestTransition @@ -53,13 +54,13 @@ import kotlinx.coroutines.launch @Composable fun LibraryScreen( onComicClick: (String) -> Unit, - onQuoteClick: (String, Int) -> Unit, + /** BUG-CANDIDATE-01: Pass the full SavedQuote so the reader can use structured position. */ + onQuoteClick: (comicId: String, page: Int, quote: io.leostrange.mrcomic.core.data.db.entity.SavedQuote?) -> Unit, onAddFileClick: () -> Unit, onAddFolderClick: () -> Unit, onSettingsClick: () -> Unit, onAudiobookClick: (String) -> Unit, onProgressProfileClick: (() -> Unit)? = null, - onOpdsCatalogClick: (() -> Unit)? = null, viewModel: LibraryViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -334,7 +335,6 @@ fun LibraryScreen( onThumbnailModeChange = viewModel::setThumbnailMode, onAddFileClick = onAddFileClick, onAddFolderClick = onAddFolderClick, - onOpdsCatalogClick = onOpdsCatalogClick, canNavigateUp = canNavigateUpWithinLibrary, onNavigateUp = navigateUpWithinLibrary, onSettingsClick = onSettingsClick @@ -356,9 +356,12 @@ fun LibraryScreen( LibraryBackground( backgroundStyle = uiState.backgroundStyle, backgroundImageUri = uiState.backgroundImageUri, - backdropStrength = (uiState.backdropStrength * 1.2f).coerceIn(0f, 1f), + // Editorial Ink: use the user-controlled strength directly + // (no 1.2× boost). BUG-UI-04 — backdrops were overpowering + // content and washing out card contrast. + backdropStrength = uiState.backdropStrength.coerceIn(0f, 1f), backgroundBlur = uiState.backgroundBlur, - backgroundVeil = (uiState.backgroundVeil * 1.2f).coerceIn(0f, 1f) + backgroundVeil = uiState.backgroundVeil.coerceIn(0f, 1f) ) Box( @@ -467,7 +470,12 @@ fun LibraryScreen( } LazyVerticalGrid( columns = columns, - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp), + contentPadding = PaddingValues( + start = MrComicSpacingTokens.x5, + end = MrComicSpacingTokens.x5, + top = MrComicSpacingTokens.x3, + bottom = MrComicSpacingTokens.x6, + ), horizontalArrangement = Arrangement.spacedBy(itemSpacing), verticalArrangement = Arrangement.spacedBy(itemSpacing), modifier = Modifier.fillMaxSize() diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt index 62daaa908..5ef7e9f64 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibraryScreenContent.kt @@ -126,7 +126,8 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( isBookmarkSection: Boolean, isAchievementSection: Boolean, strings: AppStrings, - onQuoteClick: (String, Int) -> Unit, + /** BUG-CANDIDATE-01: Pass the full SavedQuote so the reader can use structured position. */ + onQuoteClick: (comicId: String, page: Int, quote: SavedQuote?) -> Unit, onComicClick: (String) -> Unit, onAudiobookClick: (String) -> Unit, onSelectComicId: (String) -> Unit, @@ -142,11 +143,11 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( EmptyQuotesPlaceholder(showMascot = uiState.mascotUiEnabled) } } else { - items(uiState.quotes, key = { "quote_${it.id}" }) { quote -> + items(uiState.quotes, key = { "quote_${it.id}" }, contentType = { "quote" }) { quote -> QuoteCard( quote = quote, sourceAvailable = quote.comicId in uiState.availableQuoteComicIds, - onClick = { onQuoteClick(quote.comicId, quote.page) }, + onClick = { onQuoteClick(quote.comicId, quote.page, quote) }, onLongClick = { onSetQuoteToDelete(quote) }, onUnavailableSourceClick = onShowMissingSourceSnackbar ) @@ -202,7 +203,7 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( .padding(top = 8.dp, bottom = 4.dp) ) } - items(comics, key = { it.id }) { comic -> + items(comics, key = { it.id }, contentType = { "comic" }) { comic -> LibraryGridCell( isGrid = uiState.viewMode != LibraryViewMode.LIST, tileSizeDp = uiState.tileSizeDp @@ -282,6 +283,14 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( items( items = activeDisplayItems, key = { it.key }, + contentType = { item -> + when (item) { + is LibrarySectionDividerItem -> "divider" + is LibraryComicItem -> "comic" + is LibraryFolderItem -> "folder" + else -> "unknown" + } + }, span = { item -> if (item is LibrarySectionDividerItem) { GridItemSpan(maxLineSpan) @@ -364,7 +373,7 @@ internal fun LazyGridScope.libraryDisplayItemsGridContent( icon = Icons.Default.Headphones ) } - items(visibleAudiobooks, key = { "ab_${it.id}" }) { audiobook -> + items(visibleAudiobooks, key = { "ab_${it.id}" }, contentType = { "audiobook" }) { audiobook -> LibraryGridCell( isGrid = uiState.viewMode != LibraryViewMode.LIST, tileSizeDp = uiState.tileSizeDp diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt index a050eb0ac..3ece88163 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/LibrarySections.kt @@ -17,9 +17,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.RoundedCornerShape import io.leostrange.mrcomic.core.model.* import io.leostrange.mrcomic.core.ui.library.* import io.leostrange.mrcomic.core.ui.locale.* +import io.leostrange.mrcomic.core.ui.designsystem.MrComicCornerScale +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import io.leostrange.mrcomic.feature.library.components.ComicGridItem @Composable @@ -69,7 +72,7 @@ internal fun LibrarySectionChip( val colorScheme = MaterialTheme.colorScheme Surface( modifier = Modifier.clickable(enabled = enabled, onClick = onClick), - shape = RootChromePillShape, + shape = RoundedCornerShape(MrComicCornerScale.md), color = if (enabled) { rootChromePillContainerColor(colorScheme, selected) } else { @@ -79,8 +82,8 @@ internal fun LibrarySectionChip( ) { Text( text = text, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), - style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + style = MrComicType.button, color = if (enabled) { rootChromePillContentColor(colorScheme, selected) } else { diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt index 7a43d548f..c44b33ffe 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MiniAudiobookPlayer.kt @@ -3,12 +3,12 @@ package io.leostrange.mrcomic.feature.library import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon @@ -38,6 +37,8 @@ import coil.compose.AsyncImage import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicProgressLine import io.leostrange.mrcomic.core.ui.locale.LocalStrings +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCover +import io.leostrange.mrcomic.feature.library.components.LibraryFallbackCoverKind import io.leostrange.mrcomic.core.ui.locale.audiobookPauseActionLabel import io.leostrange.mrcomic.core.ui.locale.audiobookPlayActionLabel import io.leostrange.mrcomic.core.ui.locale.audiobookStopActionLabel @@ -98,10 +99,19 @@ fun MiniAudiobookPlayer( Box( modifier = Modifier .size(40.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .clip(RoundedCornerShape(6.dp)), contentAlignment = Alignment.Center ) { + val miniFallbackKind = if (audiobook.sourceIsFolder) { + LibraryFallbackCoverKind.AUDIO_FOLDER + } else { + LibraryFallbackCoverKind.AUDIO_FILE + } + LibraryFallbackCover( + title = audiobook.title, + kind = miniFallbackKind, + modifier = Modifier.fillMaxSize(), + ) if (audiobook.coverUri != null) { AsyncImage( model = audiobook.coverUri, @@ -109,13 +119,6 @@ fun MiniAudiobookPlayer( contentScale = ContentScale.Crop, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)) ) - } else { - Icon( - imageVector = Icons.Default.Headphones, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) } } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt index b8679f7f8..4a7b42718 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/MrComicHubStrings.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.feature.library.components.LibraryAchievement import java.text.SimpleDateFormat import java.util.Calendar @@ -88,7 +89,7 @@ internal fun mrComicPresenceText( "ko" -> "작품이 몇 권 더 모이면 이 코너가 제대로 된 읽기 허브가 됩니다." else -> "Добавь несколько тайтлов, и этот уголок превратится в полноценный читательский центр." } - recentComic != null && recentComic.readingProgress in 0.05f..0.98f -> when (language) { + recentComic != null && recentComic.displayReadingProgress() in 0.05f..0.98f -> when (language) { "en" -> "You are mid-run on \"${recentComic.title}\". The shelf is already warmed up for the next session." "ja" -> "「${recentComic.title}」を読み進めています。次の読書セッションの準備はできています。" "zh" -> "你还在读《${recentComic.title}》,书架已经为下一次阅读准备好了。" diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt index 084917b9a..da61420f7 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryContentDecor.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material3.Icon @@ -95,6 +94,7 @@ internal fun BoxScope.ComicCoverTreatment( comic: Comic, shape: RoundedCornerShape, graphicCoverStyle: String = "POSTER", + showCompletedFold: Boolean = true, modifier: Modifier = Modifier ) { val normalizedGraphicStyle = normalizeLibraryGraphicCoverStyle(graphicCoverStyle) @@ -118,7 +118,7 @@ internal fun BoxScope.ComicCoverTreatment( shape = shape ) ) - if (comic.isReadCompleted()) { + if (showCompletedFold && comic.isReadCompleted()) { Surface( modifier = Modifier .align(Alignment.TopEnd) @@ -185,7 +185,7 @@ internal fun BoxScope.ComicCoverTreatment( ) ) } - if (comic.isReadCompleted()) { + if (showCompletedFold && comic.isReadCompleted()) { Surface( modifier = Modifier .align(Alignment.TopEnd) @@ -608,8 +608,22 @@ internal fun CoverArt( modifier: Modifier = Modifier, emptyGraphic: Boolean = false ) { - Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) { + val fallbackKind = if (emptyGraphic) { + LibraryFallbackCoverKind.GRAPHIC + } else { + LibraryFallbackCoverKind.BOOK + } + Box(modifier = modifier) { if (coverPath != null) { + // Crossfade is enabled globally on the shared Coil ImageLoader + // (ComicApplication.newImageLoader, 180ms): covers fade in instead of + // popping in. The fallback cover painted underneath doubles as a + // neutral placeholder during the fade-in so there is no white flash. + LibraryFallbackCover( + title = title, + kind = fallbackKind, + modifier = Modifier.fillMaxSize(), + ) AsyncImage( model = rememberCoverModel(coverPath), contentDescription = title, @@ -617,11 +631,10 @@ internal fun CoverArt( modifier = Modifier.fillMaxSize() ) } else { - Icon( - Icons.AutoMirrored.Filled.MenuBook, - contentDescription = title, - modifier = Modifier.align(Alignment.Center).size(if (emptyGraphic) 44.dp else 40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + LibraryFallbackCover( + title = title, + kind = fallbackKind, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCover.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCover.kt new file mode 100644 index 000000000..a8e42b8cb --- /dev/null +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCover.kt @@ -0,0 +1,438 @@ +package io.leostrange.mrcomic.feature.library.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.abs + +/** + * Kind of fallback cover to render. Each kind picks a distinct visual recipe + * (icon, label shape, accent) so library shelves remain readable when the + * source file has no embedded artwork. + */ +enum class LibraryFallbackCoverKind { + /** A regular book or text document. */ + BOOK, + + /** A single audiobook file (mp3/m4b/ogg/...). */ + AUDIO_FILE, + + /** A folder audiobook (multiple chapters). */ + AUDIO_FOLDER, + + /** A folder of mixed content (books, graphic volumes, etc.). */ + FOLDER, + + /** A graphic volume (comic, manga, PDF) without a cover. */ + GRAPHIC, +} + +/** + * Visual recipe for a fallback cover. The helper is pure so it can be unit + * tested without a Compose runtime; the Composable consumes it via + * [rememberLibraryFallbackCoverSpec]. + */ +data class LibraryFallbackCoverSpec( + val seedHue: Float, + val accent: Color, + val highlight: Color, + val deep: Color, + val letter: String, + val showLetter: Boolean, +) + +/** + * Build a deterministic visual spec from a title and a [LibraryFallbackCoverKind]. + * + * - [accent] / [highlight] / [deep] are derived from the active theme so the + * fallback stays on-brand across light/dark/AMOLED variants. + * - [seedHue] is a stable angle (0..360) used to position the gradient + * highlights so two cards with the same title look identical. + * - [letter] is the first non-trivial grapheme of the title. Cyrillic, + * CJK, digits and emoji are all supported — the helper trims emoji + * variation selectors and zero-width joiners. + */ +fun buildLibraryFallbackCoverSpec( + title: String, + kind: LibraryFallbackCoverKind, + primary: Color, + secondary: Color, + tertiary: Color, + onSurface: Color, +): LibraryFallbackCoverSpec { + val seedHue = titleFallbackSeedHue(title, kind) + val palette = fallbackCoverPaletteForKind( + kind = kind, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = seedHue, + ) + return LibraryFallbackCoverSpec( + seedHue = seedHue, + accent = palette.accent, + highlight = palette.highlight, + deep = palette.deep, + letter = firstTitleGrapheme(title, kind), + showLetter = kind != LibraryFallbackCoverKind.FOLDER, + ) +} + +internal data class FallbackCoverPalette( + val accent: Color, + val highlight: Color, + val deep: Color, +) + +internal fun fallbackCoverPaletteForKind( + kind: LibraryFallbackCoverKind, + primary: Color, + secondary: Color, + tertiary: Color, + seedHue: Float, +): FallbackCoverPalette { + val baseColor = when (kind) { + LibraryFallbackCoverKind.BOOK -> primary + LibraryFallbackCoverKind.GRAPHIC -> primary + LibraryFallbackCoverKind.AUDIO_FILE -> secondary + LibraryFallbackCoverKind.AUDIO_FOLDER -> secondary + LibraryFallbackCoverKind.FOLDER -> tertiary + } + // 12% of hue drift on the base keeps same-title cards stable while still + // giving a different look across distinct titles. The shift is wrapped so + // negative values stay in 0..1 range. + val hueShift = (((seedHue - 180f) / 720f) + 1f) % 1f + val hueShifted = baseColor.copy( + alpha = 1f, + red = (baseColor.red + hueShift * 0.08f).coerceIn(0f, 1f), + green = (baseColor.green + hueShift * 0.05f).coerceIn(0f, 1f), + blue = (baseColor.blue + hueShift * 0.06f).coerceIn(0f, 1f), + ) + val isLightSurface = hueShifted.luminance() > 0.55f + val highlight = lerp(hueShifted, Color.White, if (isLightSurface) 0.22f else 0.34f) + val deep = lerp(hueShifted, Color.Black, if (isLightSurface) 0.42f else 0.18f) + return FallbackCoverPalette( + accent = hueShifted, + highlight = highlight, + deep = deep, + ) +} + +internal fun titleFallbackSeedHue(title: String, kind: LibraryFallbackCoverKind): Float { + if (title.isBlank()) return kind.ordinal * 67f + var hash = 0 + var saltShift = kind.ordinal * 31 + for (codePoint in title.codePoints()) { + hash = (hash * 131 + codePoint + saltShift) xor (hash ushr 13) + saltShift = (saltShift + 17) and 0x3F + } + val normalized = abs(hash) % 36_000 + return (normalized / 100f) % 360f +} + +/** + * Pick the first meaningful grapheme of [title] for the monogram. + * + * Strips: + * - leading whitespace + * - common "The / A / An" English articles + * - leading list markers ("- ", "— ") + * - emoji variation selectors and zero-width joiners + * + * Returns "•" for blank titles so callers always have a renderable string. + */ +internal fun firstTitleGrapheme(title: String, kind: LibraryFallbackCoverKind): String { + val cleaned = title.trim() + if (cleaned.isEmpty()) return defaultFallbackLetter(kind) + // Skip leading invisible/formatting code points: emoji variation selector + // (0xFE0F), ZWJ (0x200D) and ZWSP (0x200B/0x200C). After skipping, if the + // string is empty, fall back to the kind-specific placeholder. + var offset = 0 + while (offset < cleaned.length) { + val cp = cleaned.codePointAt(offset) + val isSkippable = cp == 0xFE0F.toInt() || + cp == 0x200D.toInt() || + cp == 0x200B.toInt() || + cp == 0x200C.toInt() + if (!isSkippable) break + offset += Character.charCount(cp) + } + if (offset >= cleaned.length) return defaultFallbackLetter(kind) + val trimmed = cleaned.substring(offset) + val firstCodePoint = trimmed.codePointAt(0) + // Strip leading articles in any language we know about. Longer articles + // are checked first so that "An Apple" doesn't match the single-letter + // "a " article. + val lower = trimmed.lowercase() + val articles = listOf( + "the ", "an ", + "der ", "die ", "das ", + "le ", "la ", "les ", + "el ", "los ", "las ", + "a ", + ) + for (article in articles) { + if (lower.startsWith(article) && lower.length > article.length) { + return trimmed.substring(article.length, article.length + 1).uppercase() + } + } + // Strip common list markers. + val listMarkers = listOf("- ", "— ", "* ", "· ", "• ") + for (marker in listMarkers) { + if (trimmed.startsWith(marker) && trimmed.length > marker.length) { + return trimmed.substring(marker.length, marker.length + 1).uppercase() + } + } + return String(Character.toChars(firstCodePoint)).uppercase() +} + +private fun defaultFallbackLetter(kind: LibraryFallbackCoverKind): String = when (kind) { + LibraryFallbackCoverKind.BOOK -> "B" + LibraryFallbackCoverKind.GRAPHIC -> "G" + LibraryFallbackCoverKind.AUDIO_FILE -> "♪" + LibraryFallbackCoverKind.AUDIO_FOLDER -> "♫" + LibraryFallbackCoverKind.FOLDER -> "•" +} + +/** + * Stable spec for the active theme. Compose callers should always go through + * this helper to keep the recipe in sync with [buildLibraryFallbackCoverSpec]. + */ +@Composable +fun rememberLibraryFallbackCoverSpec( + title: String, + kind: LibraryFallbackCoverKind, +): LibraryFallbackCoverSpec { + val primary = MaterialTheme.colorScheme.primary + val secondary = MaterialTheme.colorScheme.secondary + val tertiary = MaterialTheme.colorScheme.tertiary + val onSurface = MaterialTheme.colorScheme.onSurface + return remember(title, kind, primary, secondary, tertiary, onSurface) { + buildLibraryFallbackCoverSpec( + title = title, + kind = kind, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + } +} + +/** + * Drop-in fallback cover for any library card whose source file has no + * embedded artwork. Renders a soft diagonal gradient with a monogram letter + * and a small type icon, both themed to the current palette. + */ +@Composable +fun LibraryFallbackCover( + title: String, + kind: LibraryFallbackCoverKind, + modifier: Modifier = Modifier, + shape: Shape? = null, + showIcon: Boolean = true, + letterFontSize: TextUnit = 28.sp, + iconSize: Dp = 18.dp, +) { + val spec = rememberLibraryFallbackCoverSpec(title = title, kind = kind) + val onAccent = if (spec.accent.luminance() > 0.55f) Color.Black else Color.White + val baseModifier = if (shape != null) modifier.clip(shape) else modifier + Box(modifier = baseModifier.background(spec.deep)) { + FallbackCoverBackdrop( + accent = spec.accent, + highlight = spec.highlight, + seedHue = spec.seedHue, + modifier = Modifier.fillMaxSize(), + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (showIcon) { + Icon( + imageVector = fallbackCoverIcon(kind), + contentDescription = null, + tint = onAccent.copy(alpha = 0.78f), + modifier = Modifier.size(iconSize), + ) + } else { + Box(modifier = Modifier.size(iconSize)) + } + if (spec.showLetter) { + Text( + text = spec.letter, + color = onAccent.copy(alpha = 0.94f), + fontWeight = FontWeight.Black, + fontSize = letterFontSize, + maxLines = 1, + textAlign = TextAlign.Center, + overflow = TextOverflow.Visible, + ) + } + } + } +} + +/** + * Backwards-compatible convenience wrapper that renders a folder-style + * cover (icon only, no monogram). The legacy call sites use this shape + * for LibraryFolderItem and audiobook folder covers. + */ +@Composable +fun LibraryFallbackFolderCover( + title: String, + kind: LibraryFallbackCoverKind = LibraryFallbackCoverKind.FOLDER, + modifier: Modifier = Modifier, + shape: Shape? = null, +) { + LibraryFallbackCover( + title = title, + kind = kind, + modifier = modifier, + shape = shape, + showIcon = true, + letterFontSize = 18.sp, + iconSize = 28.dp, + ) +} + +/** + * Small badge-style fallback (used in the mini-player and audio list rows). + */ +@Composable +fun LibraryFallbackCoverBadge( + title: String, + kind: LibraryFallbackCoverKind, + modifier: Modifier = Modifier, + shape: Shape? = null, + iconSize: Dp = 18.dp, + showLetter: Boolean = false, +) { + val spec = rememberLibraryFallbackCoverSpec(title = title, kind = kind) + val onAccent = if (spec.accent.luminance() > 0.55f) Color.Black else Color.White + val baseModifier = if (shape != null) modifier.clip(shape) else modifier + Box(modifier = baseModifier.background(spec.accent), contentAlignment = Alignment.Center) { + Icon( + imageVector = fallbackCoverIcon(kind), + contentDescription = null, + tint = onAccent.copy(alpha = 0.92f), + modifier = Modifier.size(iconSize), + ) + if (showLetter) { + Text( + text = spec.letter, + color = onAccent.copy(alpha = 0.96f), + fontWeight = FontWeight.Black, + fontSize = 16.sp, + maxLines = 1, + ) + } + } +} + +internal fun fallbackCoverIcon(kind: LibraryFallbackCoverKind) = when (kind) { + LibraryFallbackCoverKind.BOOK, + LibraryFallbackCoverKind.GRAPHIC -> Icons.AutoMirrored.Filled.MenuBook + LibraryFallbackCoverKind.AUDIO_FILE -> Icons.Filled.Headphones + LibraryFallbackCoverKind.AUDIO_FOLDER -> Icons.Filled.LibraryMusic + LibraryFallbackCoverKind.FOLDER -> Icons.Filled.FolderOpen +} + +@Composable +private fun BoxScope.FallbackCoverBackdrop( + accent: Color, + highlight: Color, + seedHue: Float, + modifier: Modifier = Modifier, +) { + val angle = (seedHue / 360f) * (Math.PI * 2).toFloat() + val centerX = 0.5f + kotlin.math.cos(angle) * 0.18f + val centerY = 0.5f + kotlin.math.sin(angle) * 0.18f + val highlightCenter = Offset( + x = (0.5f + kotlin.math.cos(angle + Math.PI.toFloat()) * 0.32f).coerceIn(0f, 1f), + y = (0.5f + kotlin.math.sin(angle + Math.PI.toFloat()) * 0.32f).coerceIn(0f, 1f), + ) + Canvas(modifier = modifier) { + // Diagonal wash. + drawRect( + brush = Brush.linearGradient( + colors = listOf(highlight, accent), + start = Offset(size.width * 0.05f, size.height * 0.05f), + end = Offset(size.width * 0.95f, size.height * 0.95f), + ), + ) + // Soft accent blob in the upper third. + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + highlight.copy(alpha = 0.62f), + accent.copy(alpha = 0.18f), + Color.Transparent, + ), + center = Offset(size.width * centerX, size.height * centerY), + radius = size.maxDimension * 0.72f, + ), + radius = size.maxDimension * 0.72f, + center = Offset(size.width * centerX, size.height * centerY), + ) + // Bottom shadow to keep letters readable. + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.18f), + ), + startY = size.height * 0.55f, + endY = size.height, + ), + topLeft = Offset(0f, size.height * 0.55f), + size = androidx.compose.ui.geometry.Size(size.width, size.height * 0.45f), + ) + // Sparkle highlight in the opposite quadrant. + drawCircle( + color = Color.White.copy(alpha = 0.18f), + radius = size.minDimension * 0.06f, + center = Offset(size.width * highlightCenter.x, size.height * highlightCenter.y), + ) + } +} + +internal fun DrawScope.drawFallbackCoverDebugOverlay() { + // Reserved for future visual debug overlays; intentionally empty. +} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt index 2bdef0733..5126b4922 100644 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt +++ b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBar.kt @@ -13,7 +13,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.ViewList -import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Crop169 import androidx.compose.material.icons.filled.CropSquare import androidx.compose.material.icons.filled.FolderOpen @@ -34,10 +33,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import io.leostrange.mrcomic.core.ui.designsystem.MrComicTypographyTokens +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicIconButtonVariant import io.leostrange.mrcomic.core.model.SortOrder @@ -69,7 +67,6 @@ fun LibraryTopBar( onThumbnailModeChange: (String) -> Unit, onAddFileClick: () -> Unit, onAddFolderClick: () -> Unit, - onOpdsCatalogClick: (() -> Unit)? = null, canNavigateUp: Boolean, onNavigateUp: () -> Unit, onSettingsClick: () -> Unit @@ -102,11 +99,7 @@ fun LibraryTopBar( LibraryContentSection.ACHIEVEMENTS -> strings.libraryAchievements else -> strings.navLibrary }, - style = MaterialTheme.typography.displaySmall.copy( - fontSize = MrComicTypographyTokens.libraryTitle, - fontWeight = FontWeight.Black, - letterSpacing = MrComicTypographyTokens.LetterSpacing.display - ), + style = MrComicType.h1, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -225,17 +218,6 @@ fun LibraryTopBar( ) } } - if (onOpdsCatalogClick != null) { - MrComicIconButton( - onClick = onOpdsCatalogClick, - variant = MrComicIconButtonVariant.Tonal - ) { - Icon( - Icons.Default.CloudDownload, - contentDescription = strings.opdsCatalog - ) - } - } } } MrComicIconButton( diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogController.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogController.kt deleted file mode 100644 index 8bae8a3e2..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogController.kt +++ /dev/null @@ -1,241 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import android.util.Log -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.io.File - -/** - * OPDS catalog browsing, search and book download (4.2 slice 4). - * - * Extracted from OpdsCatalogViewModel into an explicit-dependency controller - * (delegate-controller pattern, AGENTS.md): the ViewModel stays the single - * owner of state and lifecycle; this controller needs only the repository, - * the scope and the UI state flow. - */ -internal class OpdsCatalogController( - private val opdsRepository: OpdsRepository, - private val scope: CoroutineScope, - private val uiState: MutableStateFlow, -) { - - companion object { - private const val TAG = "OpdsCatalogViewModel" - } - - private var feedRequestJob: Job? = null - - init { - uiState.update { it.copy(catalogs = opdsRepository.defaultCatalogs) } - } - - /** Open a catalog source. */ - fun openCatalog(source: OpdsCatalogSource) { - uiState.update { - it.copy( - showCatalogPicker = false, - currentFeed = null, - feedStack = listOf(source.url), - searchQuery = "", - isSearchMode = false - ) - } - loadFeed(source.url) - } - - /** Navigate to a sub-feed (catalog or next page). */ - fun navigateTo(url: String) { - uiState.update { - it.copy( - showCatalogPicker = false, - isSearchMode = false, - searchQuery = "", - feedStack = it.feedStack + url - ) - } - loadFeed(url) - } - - /** Go back to the previous feed. */ - fun goBack() { - val stack = uiState.value.feedStack - if (stack.size <= 1) { - feedRequestJob?.cancel() - uiState.update { - it.copy( - showCatalogPicker = true, - currentFeed = null, - feedStack = emptyList(), - isLoading = false, - error = null, - isSearchMode = false, - searchQuery = "", - failedDownload = null - ) - } - return - } - val newStack = stack.dropLast(1) - uiState.update { - it.copy( - feedStack = newStack, - isSearchMode = false, - searchQuery = "", - failedDownload = null - ) - } - loadFeed(newStack.last()) - } - - /** Load the next page of the current feed. */ - fun loadNextPage() { - val nextLink = uiState.value.currentFeed?.nextLink ?: return - navigateTo(nextLink) - } - - /** Start a search. */ - fun search(query: String) { - val feed = uiState.value.currentFeed ?: return - val searchUrl = feed.searchLink ?: return - feedRequestJob?.cancel() - uiState.update { - it.copy( - isSearchMode = true, - searchQuery = query, - isLoading = true, - error = null, - failedDownload = null - ) - } - feedRequestJob = scope.launch { - try { - val result = opdsRepository.search(searchUrl, query) - uiState.update { it.copy(currentFeed = result, isLoading = false) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Search failed: $query", e) - uiState.update { it.copy(isLoading = false, error = e.message ?: "Search failed") } - } - } - } - - /** Retry the currently visible feed or search request. */ - fun retry() { - uiState.value.failedDownload?.let { entry -> - uiState.update { it.copy(error = null, failedDownload = null) } - downloadBook(entry) - return - } - if (uiState.value.isSearchMode && uiState.value.searchQuery.isNotBlank()) { - search(uiState.value.searchQuery) - } else { - uiState.value.feedStack.lastOrNull()?.let(::loadFeed) - } - } - - /** Exit search mode and return to the current catalog. */ - fun exitSearch() { - uiState.update { it.copy(isSearchMode = false, searchQuery = "") } - uiState.value.feedStack.lastOrNull()?.let(::loadFeed) - } - - /** Download a book from an OPDS entry. */ - fun downloadBook(entry: OpdsEntry) { - // Acquisition href is stable per resource and avoids title collisions. - val progressKey = entry.acquisitionLink?.href ?: entry.title - if (progressKey in uiState.value.downloadProgress) return - - scope.launch { - uiState.update { - it.copy( - error = null, - failedDownload = null, - downloadProgress = it.downloadProgress + (progressKey to 0f) - ) - } - try { - val file = opdsRepository.downloadBook(entry) { bytesRead, totalBytes -> - val progress = if (totalBytes > 0) bytesRead.toFloat() / totalBytes else 0f - uiState.update { it.copy(downloadProgress = it.downloadProgress + (progressKey to progress)) } - } - uiState.update { - it.copy( - downloadedBooks = it.downloadedBooks + file, - downloadProgress = it.downloadProgress - progressKey, - failedDownload = null - ) - } - Log.d(TAG, "Downloaded: ${file.name} (${file.length()} bytes)") - } catch (e: CancellationException) { - uiState.update { it.copy(downloadProgress = it.downloadProgress - progressKey) } - throw e - } catch (e: Exception) { - Log.e(TAG, "Download failed: ${entry.title}", e) - uiState.update { - it.copy( - error = "Download failed: ${e.message}", - downloadProgress = it.downloadProgress - progressKey, - failedDownload = entry - ) - } - } - } - } - - /** Clear one queued downloaded book after it has been imported. */ - fun clearDownloadedBook(file: File) { - uiState.update { state -> - state.copy(downloadedBooks = state.downloadedBooks - file) - } - } - - /** Clear the first queued downloaded book after it has been imported. */ - fun clearDownloadedBook() { - uiState.value.downloadedBooks.firstOrNull()?.let(::clearDownloadedBook) - } - - /** Show the catalog picker again. */ - fun showCatalogPicker() { - feedRequestJob?.cancel() - uiState.update { - it.copy( - showCatalogPicker = true, - currentFeed = null, - feedStack = emptyList(), - isLoading = false, - isSearchMode = false, - searchQuery = "", - failedDownload = null - ) - } - } - - /** Clear error state. */ - fun clearError() { - uiState.update { it.copy(error = null) } - } - - private fun loadFeed(url: String) { - feedRequestJob?.cancel() - uiState.update { it.copy(isLoading = true, error = null, failedDownload = null) } - feedRequestJob = scope.launch { - try { - val feed = opdsRepository.browse(url) - uiState.update { it.copy(currentFeed = feed, isLoading = false) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Failed to load feed: $url", e) - uiState.update { it.copy(isLoading = false, error = e.message ?: "Unknown error") } - } - } - } -} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogScreen.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogScreen.kt deleted file mode 100644 index b7dc5df91..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogScreen.kt +++ /dev/null @@ -1,317 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import coil.compose.AsyncImage -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.ui.locale.LocalStrings - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OpdsCatalogScreen( - onNavigateBack: () -> Unit, - onBookDownloaded: (java.io.File) -> Unit, - viewModel: OpdsCatalogViewModel = hiltViewModel() -) { - val uiState by viewModel.uiState.collectAsState() - val strings = LocalStrings.current - - LaunchedEffect(uiState.downloadedBooks) { - uiState.downloadedBooks.firstOrNull()?.let { file -> - onBookDownloaded(file) - viewModel.clearDownloadedBook(file) - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { - if (uiState.isSearchMode) { - Text("${strings.opdsSearch}: ${uiState.searchQuery}") - } else { - Text(uiState.currentFeed?.title ?: strings.opdsCatalogs) - } - }, - navigationIcon = { - IconButton(onClick = { - if (uiState.isSearchMode) viewModel.exitSearch() - else if (!uiState.showCatalogPicker) viewModel.goBack() - else onNavigateBack() - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.back) - } - }, - actions = { - if (!uiState.showCatalogPicker && uiState.currentFeed?.searchLink != null) { - var showSearch by remember { mutableStateOf(false) } - var searchText by remember { mutableStateOf("") } - - if (showSearch) { - OutlinedTextField( - value = searchText, - onValueChange = { searchText = it }, - placeholder = { Text(strings.opdsSearchPlaceholder) }, - singleLine = true, - modifier = Modifier.weight(1f), - trailingIcon = { - IconButton(onClick = { - if (searchText.isNotBlank()) viewModel.search(searchText) - showSearch = false - }) { - Icon(Icons.Default.Search, contentDescription = strings.opdsSearch) - } - } - ) - } else { - IconButton(onClick = { showSearch = true }) { - Icon(Icons.Default.Search, contentDescription = strings.opdsSearch) - } - } - } - if (!uiState.showCatalogPicker) { - IconButton(onClick = { viewModel.showCatalogPicker() }) { - Icon(Icons.Default.List, contentDescription = strings.opdsCatalogs) - } - } - } - ) - } - ) { padding -> - Box(modifier = Modifier.padding(padding).fillMaxSize()) { - when { - uiState.isLoading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - uiState.error != null -> { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(8.dp)) - Text(uiState.error!!, color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.retry() }) { Text(strings.opdsRetry) } - } - } - uiState.showCatalogPicker -> { - CatalogPicker( - catalogs = uiState.catalogs, - onSelect = { viewModel.openCatalog(it) } - ) - } - uiState.currentFeed != null -> { - FeedContent( - feed = uiState.currentFeed!!, - downloadProgress = uiState.downloadProgress, - onEntryClick = { entry -> - when { - entry.isCatalog -> entry.navigationLink?.let { viewModel.navigateTo(it.href) } - entry.isBook -> viewModel.downloadBook(entry) - } - }, - onLoadNextPage = { viewModel.loadNextPage() } - ) - } - } - } - } -} - -private fun OpdsCatalogSource.localized(strings: io.leostrange.mrcomic.core.ui.locale.AppStrings): OpdsCatalogSource = when { - url.contains("gutenberg.org/ebooks.opds") -> copy( - name = strings.opdsProjectGutenberg, - description = strings.opdsProjectGutenbergDescription - ) - url.contains("feedbooks.com/catalog/public_domain") -> copy( - name = strings.opdsFeedbooks, - description = strings.opdsFeedbooksDescription - ) - url.contains("manybooks.net/opds") -> copy( - name = strings.opdsManyBooks, - description = strings.opdsManyBooksDescription - ) - else -> this -} - -@Composable -private fun CatalogPicker( - catalogs: List, - onSelect: (OpdsCatalogSource) -> Unit -) { - val strings = LocalStrings.current - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - Text( - strings.opdsCatalogPickerTitle, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - items(catalogs) { catalog -> - val displayCatalog = catalog.localized(strings) - Card( - modifier = Modifier.fillMaxWidth().clickable { onSelect(catalog) } - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text(displayCatalog.name, style = MaterialTheme.typography.titleMedium) - if (displayCatalog.description.isNotBlank()) { - Text( - displayCatalog.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -@Composable -private fun FeedContent( - feed: io.leostrange.mrcomic.core.model.OpdsFeed, - downloadProgress: Map, - onEntryClick: (OpdsEntry) -> Unit, - onLoadNextPage: () -> Unit -) { - val strings = LocalStrings.current - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Show navigation entries first - val navEntries = feed.entries.filter { it.isCatalog } - if (navEntries.isNotEmpty()) { - item { - Text(strings.opdsCategories, style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(bottom = 4.dp)) - } - items(navEntries) { entry -> - NavigationEntryCard(entry = entry, onClick = { onEntryClick(entry) }) - } - item { Spacer(modifier = Modifier.height(8.dp)) } - } - - // Show book entries - val bookEntries = feed.entries.filter { it.isBook } - if (bookEntries.isNotEmpty()) { - item { - Text(strings.opdsBooks, style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(bottom = 4.dp)) - } - items(bookEntries) { entry -> - BookEntryCard( - entry = entry, - downloadProgress = downloadProgress[entry.acquisitionLink?.href ?: entry.title], - onClick = { onEntryClick(entry) } - ) - } - } - - // Next page button - if (feed.nextLink != null) { - item { - Button( - onClick = onLoadNextPage, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.ArrowForward, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(strings.opdsLoadMore) - } - } - } - } -} - -@Composable -private fun NavigationEntryCard(entry: OpdsEntry, onClick: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Folder, contentDescription = null, - tint = MaterialTheme.colorScheme.primary) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text(entry.title, style = MaterialTheme.typography.bodyLarge) - entry.summary?.let { - Text(it, style = MaterialTheme.typography.bodySmall, - maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } - } - } -} - -@Composable -private fun BookEntryCard( - entry: OpdsEntry, - downloadProgress: Float?, - onClick: () -> Unit -) { - val strings = LocalStrings.current - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick) - ) { - Row(modifier = Modifier.padding(12.dp)) { - // Thumbnail - entry.thumbnailUrl?.let { url -> - AsyncImage( - model = url, - contentDescription = entry.title, - modifier = Modifier.size(60.dp, 80.dp).clip(MaterialTheme.shapes.small), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(12.dp)) - } - Column(modifier = Modifier.weight(1f)) { - Text(entry.title, style = MaterialTheme.typography.bodyLarge, maxLines = 2) - entry.author?.let { - Text(it, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - entry.summary?.let { - Text(it, style = MaterialTheme.typography.bodySmall, - maxLines = 2, overflow = TextOverflow.Ellipsis) - } - // Download progress - if (downloadProgress != null) { - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { downloadProgress.coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth() - ) - } - } - // Download icon - if (entry.acquisitionLink != null && downloadProgress == null) { - Icon(Icons.Default.Download, contentDescription = strings.opdsDownload, - tint = MaterialTheme.colorScheme.primary) - } - } - } -} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogUiState.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogUiState.kt deleted file mode 100644 index 4cf5f41f2..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogUiState.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import java.io.File - -/** UI state for the OPDS catalog browser. */ -data class OpdsCatalogUiState( - val catalogs: List = emptyList(), - val currentFeed: OpdsFeed? = null, - val feedStack: List = emptyList(), - val isLoading: Boolean = false, - val error: String? = null, - val searchQuery: String = "", - val isSearchMode: Boolean = false, - val downloadProgress: Map = emptyMap(), - val downloadedBooks: List = emptyList(), - val failedDownload: OpdsEntry? = null, - val showCatalogPicker: Boolean = true -) { - /** Backwards-compatible view of the first queued download result. */ - val downloadedBook: File? - get() = downloadedBooks.firstOrNull() -} diff --git a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModel.kt b/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModel.kt deleted file mode 100644 index 3801b5d75..000000000 --- a/android/feature-library/src/main/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.io.File -import javax.inject.Inject - -/** - * OPDS catalog browser state owner (4.2). - * - * Browsing/search/download logic lives in [OpdsCatalogController] (explicit-dependency - * controller, AGENTS.md delegate-controller pattern); this ViewModel only owns the - * state flow, the controller wiring and the public API used by [OpdsCatalogScreen]. - */ -@HiltViewModel -class OpdsCatalogViewModel @Inject constructor( - opdsRepository: OpdsRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow(OpdsCatalogUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val controller = OpdsCatalogController( - opdsRepository = opdsRepository, - scope = viewModelScope, - uiState = _uiState, - ) - - /** Open a catalog source. */ - fun openCatalog(source: OpdsCatalogSource) = controller.openCatalog(source) - - /** Navigate to a sub-feed (catalog or next page). */ - fun navigateTo(url: String) = controller.navigateTo(url) - - /** Go back to the previous feed. */ - fun goBack() = controller.goBack() - - /** Load the next page of the current feed. */ - fun loadNextPage() = controller.loadNextPage() - - /** Start a search. */ - fun search(query: String) = controller.search(query) - - /** Retry the currently visible feed or search request. */ - fun retry() = controller.retry() - - /** Exit search mode and return to the current catalog. */ - fun exitSearch() = controller.exitSearch() - - /** Download a book from an OPDS entry. */ - fun downloadBook(entry: OpdsEntry) = controller.downloadBook(entry) - - /** Clear one queued downloaded book after it has been imported. */ - fun clearDownloadedBook(file: File) = controller.clearDownloadedBook(file) - - /** Clear the first queued downloaded book after it has been imported. */ - fun clearDownloadedBook() = controller.clearDownloadedBook() - - /** Show the catalog picker again. */ - fun showCatalogPicker() = controller.showCatalogPicker() - - /** Clear error state. */ - fun clearError() = controller.clearError() -} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt index d7bd93da0..d0ccbca3b 100644 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt +++ b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryContentPipelineTest.kt @@ -1,6 +1,7 @@ package io.leostrange.mrcomic.feature.library import io.leostrange.mrcomic.core.model.Comic +import io.leostrange.mrcomic.core.model.ComicFormat import io.leostrange.mrcomic.core.model.SortOrder import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -143,6 +144,28 @@ class LibraryContentPipelineTest { assertEquals(2, derived.displayItems.size) } + @Test + fun recentlyReadExcludesNewRtfWithUnknownTotalAndStaleProgress() { + // RTF with pageCount=0, readingProgress=1.0 (stale), opened once, + // no real locator → displayReadingProgress=0, isReadingInProgress() depends on + // readingStatus which returns READING (has stable signal). Verify the pipeline + // still includes it in recentlyRead (it is READING) but that display progress is 0. + val rtf = Comic( + id = "rtf-stale", + format = ComicFormat.RTF, + pageCount = 0, + readingProgress = 1f, + lastReadDate = 100L, + ) + val state = LibraryUiState() + + val derived = pipeline.derive(state, listOf(rtf), emptyList(), listOf(rtf)) + + // The RTF book is considered READING (has lastReadDate), so it appears in recentlyRead. + assertEquals(1, derived.recentlyRead.size) + assertEquals(1, derived.readingComicCount) + } + @Test fun recentlyReadIsLimitedToTen() { val comics = (1..12).map { comic("c$it", isReading = true, lastReadDate = it.toLong()) } diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt index e9c54625d..da58d84b8 100644 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt +++ b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/LibraryStatelessHelpersTest.kt @@ -184,6 +184,12 @@ class LibraryStatelessHelpersTest { assertEquals(2, folderA.fileCount) assertEquals(2, folderA.subfolderCount) assertEquals(500, folderA.totalSize) + // pageCount=0 (default) → readingProgressForPage returns 0 → fallback to stored readingProgress. + // f1: readingProgress=1.0, lastReadDate=1 → READING → displayReadingProgress=1.0 + // f2: readingProgress=0.0, lastReadDate=1 → READING → displayReadingProgress=0.0 + // f3: readingProgress=0.5, lastReadDate=1 → READING → displayReadingProgress=0.5 + // f4: readingProgress=0.5, lastReadDate=1 → READING → displayReadingProgress=0.5 + // Average of descendants (f1,f2,f3,f4) = (1.0+0.0+0.5+0.5)/4 = 0.5 assertEquals(0.5f, folderA.progress, 0.001f) assertEquals("a", folderA.title) } diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCoverTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCoverTest.kt new file mode 100644 index 000000000..6ac2a5146 --- /dev/null +++ b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryFallbackCoverTest.kt @@ -0,0 +1,315 @@ +package io.leostrange.mrcomic.feature.library.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryFallbackCoverTest { + + private val primary = Color(0xFF3A6EA5) + private val secondary = Color(0xFFB58463) + private val tertiary = Color(0xFF5E8C61) + private val onSurface = Color(0xFF1F1F1F) + + // --- firstTitleGrapheme --- + + @Test + fun firstTitleGraphemeReturnsUppercaseForPlainAscii() { + assertEquals("H", firstTitleGrapheme("hello world", LibraryFallbackCoverKind.BOOK)) + assertEquals("M", firstTitleGrapheme("mr. comic", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingEnglishArticle() { + assertEquals("O", firstTitleGrapheme("The Old Man and the Sea", LibraryFallbackCoverKind.BOOK)) + assertEquals("A", firstTitleGrapheme("An Apple", LibraryFallbackCoverKind.BOOK)) + assertEquals("S", firstTitleGrapheme("A Short Story", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingGermanArticle() { + assertEquals("Z", firstTitleGrapheme("Der Zauberberg", LibraryFallbackCoverKind.BOOK)) + assertEquals("S", firstTitleGrapheme("Die Schatzinsel", LibraryFallbackCoverKind.BOOK)) + assertEquals("K", firstTitleGrapheme("Das Kapital", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingFrenchArticle() { + assertEquals("M", firstTitleGrapheme("Le Misanthrope", LibraryFallbackCoverKind.BOOK)) + assertEquals("P", firstTitleGrapheme("La Peste", LibraryFallbackCoverKind.BOOK)) + assertEquals("M", firstTitleGrapheme("Les Misérables", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsLeadingSpanishArticle() { + assertEquals("Q", firstTitleGrapheme("El Quixote", LibraryFallbackCoverKind.BOOK)) + assertEquals("T", firstTitleGrapheme("Los Tres", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemePreservesCyrillic() { + // Russian titles: "Братья Карамазовы", "Война и мир" + assertEquals("Б", firstTitleGrapheme("Братья Карамазовы", LibraryFallbackCoverKind.BOOK)) + assertEquals("В", firstTitleGrapheme("Война и мир", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemePreservesCjk() { + // Chinese: 红楼梦 + assertEquals("红", firstTitleGrapheme("红楼梦", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsListMarkers() { + assertEquals("A", firstTitleGrapheme("- Alpha", LibraryFallbackCoverKind.BOOK)) + assertEquals("B", firstTitleGrapheme("— Beta", LibraryFallbackCoverKind.BOOK)) + assertEquals("G", firstTitleGrapheme("· Gamma", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeSkipsEmojiVariationSelector() { + // 0xFE0F should be skipped, returning the actual letter + assertEquals("A", firstTitleGrapheme("️A", LibraryFallbackCoverKind.BOOK)) + } + + @Test + fun firstTitleGraphemeFallsBackForBlankTitle() { + assertEquals("B", firstTitleGrapheme("", LibraryFallbackCoverKind.BOOK)) + assertEquals("G", firstTitleGrapheme(" ", LibraryFallbackCoverKind.GRAPHIC)) + assertEquals("♪", firstTitleGrapheme("", LibraryFallbackCoverKind.AUDIO_FILE)) + assertEquals("♫", firstTitleGrapheme("", LibraryFallbackCoverKind.AUDIO_FOLDER)) + assertEquals("•", firstTitleGrapheme("", LibraryFallbackCoverKind.FOLDER)) + } + + @Test + fun firstTitleGraphemeFallbackForKindFollowsSemantics() { + // Sanity: each kind has a different blank-title fallback so cards + // remain visually distinct even when titles are missing. + val defaults = LibraryFallbackCoverKind.values() + .associateWith { firstTitleGrapheme("", it) } + assertEquals(5, defaults.size) + assertTrue(defaults.values.distinct().size >= 4) + } + + @Test + fun firstTitleGraphemeHandlesArticleWhereRemovingItWouldLeaveBlank() { + // If the title is just "The " (article only), we should still return the + // uppercase "T" of the article rather than a placeholder. + assertEquals("T", firstTitleGrapheme("The ", LibraryFallbackCoverKind.BOOK)) + } + + // --- buildLibraryFallbackCoverSpec --- + + @Test + fun buildSpecIsDeterministicForSameTitleAndKind() { + val a = buildLibraryFallbackCoverSpec( + title = "Война и мир", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val b = buildLibraryFallbackCoverSpec( + title = "Война и мир", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals(a.seedHue, b.seedHue, 0f) + assertEquals(a.letter, b.letter) + assertEquals(a.accent.value.toLong(), b.accent.value.toLong()) + assertEquals(a.highlight.value.toLong(), b.highlight.value.toLong()) + assertEquals(a.deep.value.toLong(), b.deep.value.toLong()) + assertEquals(a.showLetter, b.showLetter) + } + + @Test + fun buildSpecDiffersAcrossDistinctTitles() { + val a = buildLibraryFallbackCoverSpec( + title = "Alpha", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val b = buildLibraryFallbackCoverSpec( + title = "Zulu", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + // Different titles should land on different seed hues (no collisions). + assertNotEquals(a.seedHue, b.seedHue) + } + + @Test + fun buildSpecPicksBaseColorFromKind() { + val bookSpec = buildLibraryFallbackCoverSpec( + title = "Same", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val audioSpec = buildLibraryFallbackCoverSpec( + title = "Same", + kind = LibraryFallbackCoverKind.AUDIO_FILE, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + val folderSpec = buildLibraryFallbackCoverSpec( + title = "Same", + kind = LibraryFallbackCoverKind.FOLDER, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + // Kinds drive the base color. If two specs resolve to the same accent + // (after deterministic hue shift), the test would still differentiate + // them by the same accent but different letter/shows. Here we make + // sure the seeds are different so cards of different kinds are stable. + assertNotEquals(bookSpec.seedHue, audioSpec.seedHue) + assertNotEquals(audioSpec.seedHue, folderSpec.seedHue) + } + + @Test + fun buildSpecHidesLetterForFolders() { + val spec = buildLibraryFallbackCoverSpec( + title = "Manga collection", + kind = LibraryFallbackCoverKind.FOLDER, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals(false, spec.showLetter) + } + + @Test + fun buildSpecShowsLetterForBooks() { + val spec = buildLibraryFallbackCoverSpec( + title = "Manga collection", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals(true, spec.showLetter) + // Letter comes from the cleaned title (first grapheme of "Manga" -> "M"). + assertEquals("M", spec.letter) + } + + @Test + fun buildSpecStripsLeadingTheAndUppercasesFirst() { + val spec = buildLibraryFallbackCoverSpec( + title = "The Hobbit", + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + onSurface = onSurface, + ) + assertEquals("H", spec.letter) + } + + // --- fallbackCoverPaletteForKind --- + + @Test + fun paletteForKindStaysBoundedBetweenBlackAndWhite() { + LibraryFallbackCoverKind.values().forEach { kind -> + val palette = fallbackCoverPaletteForKind( + kind = kind, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = 123.4f, + ) + assertTrue( + "highlight must be <= 0.99 luminance for $kind", + palette.highlight.luminance() <= 0.99f + ) + assertTrue( + "deep must be >= 0.0 luminance for $kind", + palette.deep.luminance() >= 0f + ) + assertTrue( + "accent must be >= 0.0 luminance for $kind", + palette.accent.luminance() >= 0f + ) + } + } + + @Test + fun paletteForKindIsDeterministic() { + val a = fallbackCoverPaletteForKind( + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = 42f, + ) + val b = fallbackCoverPaletteForKind( + kind = LibraryFallbackCoverKind.BOOK, + primary = primary, + secondary = secondary, + tertiary = tertiary, + seedHue = 42f, + ) + assertEquals(a.accent.value.toLong(), b.accent.value.toLong()) + assertEquals(a.highlight.value.toLong(), b.highlight.value.toLong()) + assertEquals(a.deep.value.toLong(), b.deep.value.toLong()) + } + + // --- titleFallbackSeedHue --- + + @Test + fun seedHueIsWithin0to360ForLongTitles() { + val title = "Some very long title ".repeat(50) + for (kind in LibraryFallbackCoverKind.values()) { + val hue = titleFallbackSeedHue(title, kind) + assertTrue("seedHue=$hue out of range for $kind", hue in 0f..360f) + } + } + + @Test + fun seedHueIsWithin0to360ForEmptyTitle() { + for (kind in LibraryFallbackCoverKind.values()) { + val hue = titleFallbackSeedHue("", kind) + assertTrue("seedHue=$hue out of range for $kind", hue in 0f..360f) + } + } + + @Test + fun seedHueIsStableAcrossInvocations() { + val a = titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.BOOK) + val b = titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.BOOK) + assertEquals(a, b, 0f) + } + + @Test + fun seedHueDiffersAcrossKindsForSameTitle() { + val hues = LibraryFallbackCoverKind.values() + .map { titleFallbackSeedHue("The Return of the King", it) } + assertEquals(5, hues.size) + // We don't require every pair to differ, but the kind signal should + // introduce at least some spread (e.g. audio vs folder diverge). + assertNotEquals( + titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.AUDIO_FILE), + titleFallbackSeedHue("The Return of the King", LibraryFallbackCoverKind.FOLDER) + ) + } +} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBarOpdsTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBarOpdsTest.kt deleted file mode 100644 index b77dcf428..000000000 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/components/LibraryTopBarOpdsTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package io.leostrange.mrcomic.feature.library.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import io.leostrange.mrcomic.core.model.SortOrder -import io.leostrange.mrcomic.core.ui.locale.LocalStrings -import io.leostrange.mrcomic.core.ui.locale.appStringsForCode -import io.leostrange.mrcomic.feature.library.GroupByMode -import io.leostrange.mrcomic.feature.library.LibraryContentSection -import io.leostrange.mrcomic.feature.library.LibraryFormatFilter -import io.leostrange.mrcomic.feature.library.LibraryStatusFilter -import io.leostrange.mrcomic.feature.library.LibraryViewMode -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [35]) -class LibraryTopBarOpdsTest { - - @get:Rule - val composeRule = createComposeRule() - - private val strings = appStringsForCode("en") - - private fun setTopBar(onOpdsCatalogClick: () -> Unit) { - composeRule.setContent { - CompositionLocalProvider(LocalStrings provides strings) { - MaterialTheme { - Box { - LibraryTopBar( - contentSection = LibraryContentSection.FILES, - isControlsExpanded = true, - sortOrder = SortOrder.DATE_ADDED_DESC, - statusFilter = LibraryStatusFilter.ALL, - formatFilter = LibraryFormatFilter.ALL, - groupByMode = GroupByMode.FOLDER, - thumbnailMode = "RECTANGLE", - viewMode = LibraryViewMode.GRID, - onToggleControls = {}, - onToggleView = {}, - onOpenFilters = {}, - onThumbnailModeChange = {}, - onAddFileClick = {}, - onAddFolderClick = {}, - onOpdsCatalogClick = onOpdsCatalogClick, - canNavigateUp = false, - onNavigateUp = {}, - onSettingsClick = {} - ) - } - } - } - } - } - - @Test - fun opdsIsNotRenderedInsideLocalImportMenu() { - setTopBar {} - - composeRule.onNodeWithContentDescription(strings.actionFolder).performClick() - composeRule.onNodeWithText(strings.actionFile).assertIsDisplayed() - composeRule.onNodeWithText(strings.actionFolder).assertIsDisplayed() - composeRule.onNodeWithText(strings.opdsCatalog).assertDoesNotExist() - } - - @Test - fun opdsCloudActionNavigatesFromTopBar() { - var clicks = 0 - setTopBar { clicks++ } - - composeRule.onNodeWithContentDescription(strings.opdsCatalog).performClick() - composeRule.waitForIdle() - - assertEquals(1, clicks) - } -} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogControllerTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogControllerTest.kt deleted file mode 100644 index 8e6ab8a7b..000000000 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogControllerTest.kt +++ /dev/null @@ -1,289 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import android.util.Log -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import io.leostrange.mrcomic.core.model.OpdsLink -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.File - -@OptIn(ExperimentalCoroutinesApi::class) -class OpdsCatalogControllerTest { - - private val opdsRepository = mockk(relaxed = true) - - @Before - fun setUp() { - mockkStatic(Log::class) - every { Log.e(any(), any()) } returns 0 - every { Log.e(any(), any(), any()) } returns 0 - every { Log.d(any(), any()) } returns 0 - every { Log.d(any(), any(), any()) } returns 0 - } - - @After - fun tearDown() { - unmockkStatic(Log::class) - } - - private fun createController( - scope: CoroutineScope, - uiState: MutableStateFlow, - ) = OpdsCatalogController( - opdsRepository = opdsRepository, - scope = scope, - uiState = uiState, - ) - - private fun feed( - title: String = "Feed", - entries: List = emptyList(), - nextLink: String? = null, - searchLink: String? = null, - ) = OpdsFeed(title = title, entries = entries, links = emptyList(), nextLink = nextLink, searchLink = searchLink) - - private fun acquisitionEntry(title: String = "Book", href: String = "https://example.com/book.epub") = - OpdsEntry(title = title, links = listOf(OpdsLink(href = href, rel = "http://opds-spec.org/acquisition"))) - - @Test - fun catalogsSeededFromRepositoryDefaults() = runTest { - val source = OpdsCatalogSource(name = "Gutenberg", url = "https://gutenberg.org/opds") - every { opdsRepository.defaultCatalogs } returns listOf(source) - val uiState = MutableStateFlow(OpdsCatalogUiState()) - - createController(scope = this, uiState = uiState) - - assertEquals(listOf(source), uiState.value.catalogs) - } - - @Test - fun openCatalogLoadsFeedAndHidesPicker() = runTest { - val source = OpdsCatalogSource(name = "Gutenberg", url = "https://gutenberg.org/opds") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.browse(source.url) } returns feed(title = "Gutenberg") - val controller = createController(scope = this, uiState = uiState) - - controller.openCatalog(source) - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse(source.url) } - assertFalse(uiState.value.showCatalogPicker) - assertEquals(listOf(source.url), uiState.value.feedStack) - assertEquals("Gutenberg", uiState.value.currentFeed?.title) - assertFalse(uiState.value.isLoading) - } - - @Test - fun navigateToAppendsUrlToStackAndLoadsFeed() = runTest { - val uiState = MutableStateFlow(OpdsCatalogUiState(feedStack = listOf("https://example.com/root"))) - coEvery { opdsRepository.browse("https://example.com/sub") } returns feed(title = "Sub") - val controller = createController(scope = this, uiState = uiState) - - controller.navigateTo("https://example.com/sub") - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/sub") } - assertEquals(listOf("https://example.com/root", "https://example.com/sub"), uiState.value.feedStack) - assertEquals("Sub", uiState.value.currentFeed?.title) - } - - @Test - fun goBackPopsStackAndReloadsPreviousFeed() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState( - feedStack = listOf("https://example.com/a", "https://example.com/b", "https://example.com/c"), - showCatalogPicker = false, - ) - ) - coEvery { opdsRepository.browse("https://example.com/b") } returns feed(title = "B") - val controller = createController(scope = this, uiState = uiState) - - controller.goBack() - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/b") } - assertEquals(listOf("https://example.com/a", "https://example.com/b"), uiState.value.feedStack) - assertEquals("B", uiState.value.currentFeed?.title) - assertFalse(uiState.value.showCatalogPicker) - } - - @Test - fun goBackAtRootReturnsToCatalogPicker() = runTest { - val uiState = MutableStateFlow(OpdsCatalogUiState(feedStack = listOf("https://example.com/a"))) - val controller = createController(scope = this, uiState = uiState) - - controller.goBack() - advanceUntilIdle() - - coVerify(exactly = 0) { opdsRepository.browse(any()) } - assertTrue(uiState.value.showCatalogPicker) - assertNull(uiState.value.currentFeed) - assertEquals(emptyList(), uiState.value.feedStack) - } - - @Test - fun loadNextPageNavigatesToCurrentFeedNextLink() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState( - feedStack = listOf("https://example.com/page1"), - currentFeed = feed(nextLink = "https://example.com/page2"), - ) - ) - coEvery { opdsRepository.browse("https://example.com/page2") } returns feed(title = "Page 2") - val controller = createController(scope = this, uiState = uiState) - - controller.loadNextPage() - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/page2") } - assertEquals(listOf("https://example.com/page1", "https://example.com/page2"), uiState.value.feedStack) - } - - @Test - fun loadNextPageWithoutNextLinkIsNoOp() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState(feedStack = listOf("https://example.com/a"), currentFeed = feed()) - ) - val controller = createController(scope = this, uiState = uiState) - - controller.loadNextPage() - advanceUntilIdle() - - coVerify(exactly = 0) { opdsRepository.browse(any()) } - assertEquals(listOf("https://example.com/a"), uiState.value.feedStack) - } - - @Test - fun searchUpdatesFeedAndSearchState() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState(currentFeed = feed(searchLink = "https://example.com/search?q={searchTerms}")) - ) - coEvery { opdsRepository.search(any(), "harry") } returns feed(title = "Results") - val controller = createController(scope = this, uiState = uiState) - - controller.search("harry") - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.search("https://example.com/search?q={searchTerms}", "harry") } - assertTrue(uiState.value.isSearchMode) - assertEquals("harry", uiState.value.searchQuery) - assertEquals("Results", uiState.value.currentFeed?.title) - assertFalse(uiState.value.isLoading) - } - - @Test - fun searchWithoutSearchLinkIsNoOp() = runTest { - val uiState = MutableStateFlow(OpdsCatalogUiState(currentFeed = feed())) - val controller = createController(scope = this, uiState = uiState) - - controller.search("harry") - advanceUntilIdle() - - coVerify(exactly = 0) { opdsRepository.search(any(), any()) } - assertFalse(uiState.value.isSearchMode) - } - - @Test - fun searchFailureSetsError() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState(currentFeed = feed(searchLink = "https://example.com/search?q={searchTerms}")) - ) - coEvery { opdsRepository.search(any(), any()) } throws RuntimeException("network down") - val controller = createController(scope = this, uiState = uiState) - - controller.search("harry") - advanceUntilIdle() - - assertFalse(uiState.value.isLoading) - assertEquals("network down", uiState.value.error) - } - - @Test - fun exitSearchClearsModeAndReloadsLastFeed() = runTest { - val uiState = MutableStateFlow( - OpdsCatalogUiState( - feedStack = listOf("https://example.com/a"), - isSearchMode = true, - searchQuery = "harry", - ) - ) - coEvery { opdsRepository.browse("https://example.com/a") } returns feed(title = "A") - val controller = createController(scope = this, uiState = uiState) - - controller.exitSearch() - advanceUntilIdle() - - coVerify(exactly = 1) { opdsRepository.browse("https://example.com/a") } - assertFalse(uiState.value.isSearchMode) - assertEquals("", uiState.value.searchQuery) - assertEquals("A", uiState.value.currentFeed?.title) - } - - @Test - fun downloadBookTracksProgressAndClearsOnSuccess() = runTest { - val entry = acquisitionEntry() - val file = File("/tmp/book.epub") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.downloadBook(eq(entry), any()) } answers { - secondArg<(Long, Long) -> Unit>().invoke(50, 100) - file - } - val controller = createController(scope = this, uiState = uiState) - - controller.downloadBook(entry) - advanceUntilIdle() - - assertEquals(listOf(file), uiState.value.downloadedBooks) - assertFalse(uiState.value.downloadProgress.containsKey(entry.acquisitionLink?.href)) - assertNull(uiState.value.error) - } - - @Test - fun downloadBookFailureSetsErrorAndClearsProgress() = runTest { - val entry = acquisitionEntry(title = "Broken", href = "https://example.com/broken.epub") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.downloadBook(eq(entry), any()) } throws RuntimeException("no space") - val controller = createController(scope = this, uiState = uiState) - - controller.downloadBook(entry) - advanceUntilIdle() - - assertTrue(uiState.value.downloadedBooks.isEmpty()) - assertEquals("Download failed: no space", uiState.value.error) - assertFalse(uiState.value.downloadProgress.containsKey("https://example.com/broken.epub")) - } - - @Test - fun loadFeedFailureSetsError() = runTest { - val source = OpdsCatalogSource(name = "Gutenberg", url = "https://gutenberg.org/opds") - val uiState = MutableStateFlow(OpdsCatalogUiState()) - coEvery { opdsRepository.browse(source.url) } throws RuntimeException("timeout") - val controller = createController(scope = this, uiState = uiState) - - controller.openCatalog(source) - advanceUntilIdle() - - assertFalse(uiState.value.isLoading) - assertEquals("timeout", uiState.value.error) - assertNull(uiState.value.currentFeed) - } -} diff --git a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModelTest.kt b/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModelTest.kt deleted file mode 100644 index 8ec4900e6..000000000 --- a/android/feature-library/src/test/java/io/leostrange/mrcomic/feature/library/opds/OpdsCatalogViewModelTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -package io.leostrange.mrcomic.feature.library.opds - -import io.leostrange.mrcomic.core.data.opds.OpdsRepository -import io.leostrange.mrcomic.core.model.OpdsCatalogSource -import io.leostrange.mrcomic.core.model.OpdsEntry -import io.leostrange.mrcomic.core.model.OpdsFeed -import io.leostrange.mrcomic.core.model.OpdsLink -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.io.File -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class OpdsCatalogViewModelTest { - - private val repository = mockk() - private val mainDispatcher = StandardTestDispatcher() - - @Before - fun setUp() { - Dispatchers.setMain(mainDispatcher) - every { repository.defaultCatalogs } returns emptyList() - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun concurrentDownloadsQueueBothResultsAndKeepProgressIndependent() = runTest { - val firstEntry = bookEntry("same title", "https://example.test/one.epub") - val secondEntry = bookEntry("same title", "https://example.test/two.epub") - val firstFile = File("one.epub") - val secondFile = File("two.epub") - coEvery { repository.downloadBook(any(), any()) } answers { - when (firstArg().acquisitionLink?.href) { - firstEntry.acquisitionLink?.href -> firstFile - else -> secondFile - } - } - val viewModel = OpdsCatalogViewModel(repository) - - viewModel.downloadBook(firstEntry) - viewModel.downloadBook(secondEntry) - advanceUntilIdle() - - assertEquals(listOf(firstFile, secondFile), viewModel.uiState.value.downloadedBooks) - assertTrue(viewModel.uiState.value.downloadProgress.isEmpty()) - } - - @Test - fun retryReloadsFailedCatalog() = runTest { - val source = OpdsCatalogSource("Test", "https://example.test/catalog") - val feed = feed("Recovered") - coEvery { repository.browse(source.url) } throws RuntimeException("offline") andThen feed - val viewModel = OpdsCatalogViewModel(repository) - - viewModel.openCatalog(source) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.error!!.contains("offline")) - - viewModel.retry() - advanceUntilIdle() - - assertEquals(feed, viewModel.uiState.value.currentFeed) - assertNull(viewModel.uiState.value.error) - assertFalse(viewModel.uiState.value.isLoading) - } - - @Test - fun navigatingToNewFeedCancelsStaleRequest() = runTest { - val firstSource = OpdsCatalogSource("First", "https://example.test/first") - val secondSource = OpdsCatalogSource("Second", "https://example.test/second") - val firstResponse = CompletableDeferred() - val secondResponse = CompletableDeferred() - coEvery { repository.browse(firstSource.url) } coAnswers { firstResponse.await() } - coEvery { repository.browse(secondSource.url) } coAnswers { secondResponse.await() } - val viewModel = OpdsCatalogViewModel(repository) - - viewModel.openCatalog(firstSource) - advanceUntilIdle() - viewModel.navigateTo(secondSource.url) - secondResponse.complete(feed("Second feed")) - firstResponse.complete(feed("Stale first feed")) - advanceUntilIdle() - - assertEquals("Second feed", viewModel.uiState.value.currentFeed?.title) - } - - private fun bookEntry(title: String, href: String) = OpdsEntry( - title = title, - links = listOf( - OpdsLink( - href = href, - rel = "http://opds-spec.org/acquisition/open-access", - type = "application/epub+zip" - ) - ) - ) - - private fun feed(title: String) = OpdsFeed( - title = title, - entries = emptyList(), - links = emptyList() - ) -} diff --git a/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt b/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt index a3f46c316..d63e4aa10 100644 --- a/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt +++ b/android/feature-onboarding/src/main/java/io/leostrange/mrcomic/feature/onboarding/OnboardingViewModel.kt @@ -45,6 +45,7 @@ class OnboardingViewModel @Inject constructor( prefs.set(PreferencesKeys.READER_PAGE_ANIMATION, style.pageAnimation) prefs.set(PreferencesKeys.READER_PAGE_SOUND, false) prefs.set(PreferencesKeys.TEXT_COLOR_SCHEME, style.textColorScheme) + prefs.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, style.textColorScheme) prefs.set(PreferencesKeys.TEXT_FONT_FAMILY, style.fontFamily) prefs.set(PreferencesKeys.TEXT_LINE_HEIGHT, style.lineHeight) prefs.set(PreferencesKeys.TEXT_LETTER_SPACING, style.letterSpacing) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetector.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetector.kt new file mode 100644 index 000000000..fb401a95a --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetector.kt @@ -0,0 +1,146 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * Automatic detection of empty page margins ("Автоматическая" preset). + * + * The core works on a small luminance sampling grid so it stays pure Kotlin + * and unit-testable; the Android Bitmap adapter lives in the UI layer. + */ +object MarginCropAutoDetector { + + /** + * @param width sample grid width + * @param height sample grid height + * @param luminance luminance sample in 0..255 at grid position (x, y) + */ + fun detect( + width: Int, + height: Int, + luminance: (Int, Int) -> Int + ): ReaderMarginCropSides { + if (width < MIN_GRID || height < MIN_GRID) return ReaderMarginCropSides() + + val background = backgroundLuminance(width, height, luminance) + + val columnHasContent: (Int) -> Boolean = { x -> + lineHasContent(height, background) { y -> luminance(x, y) } + } + val rowHasContent: (Int) -> Boolean = { y -> + lineHasContent(width, background) { x -> luminance(x, y) } + } + + val left = firstContentLine(width, columnHasContent) + val right = width - 1 - lastContentLine(width, columnHasContent) + val top = firstContentLine(height, rowHasContent) + val bottom = height - 1 - lastContentLine(height, rowHasContent) + + return ReaderMarginCropSides( + left = lineFraction(left, width), + right = lineFraction(right, width), + top = lineFraction(top, height), + bottom = lineFraction(bottom, height) + ) + } + + /** + * Background estimate: the median of the four corner patches. Page scans + * have uniform paper near the borders, so corners reliably represent the + * empty margin color even when the content block is dark. + */ + private fun backgroundLuminance( + width: Int, + height: Int, + luminance: (Int, Int) -> Int + ): Int { + val patch = min(PATCH, min(width, height) / 2) + val cornerOffsets = listOf( + 0 to 0, + width - patch to 0, + 0 to height - patch, + width - patch to height - patch + ) + val cornerMedians = cornerOffsets.map { (ox, oy) -> + val samples = buildList { + for (y in oy until oy + patch) { + for (x in ox until ox + patch) add(luminance(x, y)) + } + } + median(samples) + } + return median(cornerMedians) + } + + /** + * A border line counts as content when at least [CONTENT_LINE_SHARE]% + * of its samples differ from the paper luminance by more than + * [LUMINANCE_TOLERANCE]. The line-share test ignores specks and dust. + */ + private inline fun lineHasContent( + length: Int, + background: Int, + sample: (Int) -> Int + ): Boolean { + var contentSamples = 0 + val threshold = max(1, length * CONTENT_LINE_SHARE / 100) + for (i in 0 until length) { + if (abs(sample(i) - background) > LUMINANCE_TOLERANCE) { + contentSamples++ + if (contentSamples >= threshold) return true + } + } + return false + } + + private inline fun firstContentLine( + dimension: Int, + hasContent: (Int) -> Boolean + ): Int { + val capIndex = min(dimension - 1, (dimension * ReaderMarginCrop.MAX_SIDE_FRACTION).toInt()) + for (index in 0..capIndex) { + if (hasContent(index)) return index + } + // Content starts beyond the cap (extreme margins): crop up to the cap… + for (index in capIndex + 1 until dimension) { + if (hasContent(index)) return capIndex + } + // …but a fully blank page keeps its full size (never over-crop). + return 0 + } + + private inline fun lastContentLine( + dimension: Int, + hasContent: (Int) -> Boolean + ): Int { + val capIndex = max(0, dimension - 1 - (dimension * ReaderMarginCrop.MAX_SIDE_FRACTION).toInt()) + for (index in dimension - 1 downTo capIndex) { + if (hasContent(index)) return index + } + for (index in capIndex - 1 downTo 0) { + if (hasContent(index)) return capIndex + } + return dimension - 1 + } + + private fun lineFraction(lineIndex: Int, dimension: Int): Float = + (lineIndex.toFloat() / dimension).coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION) + + private fun median(values: List): Int { + if (values.isEmpty()) return 0 + val sorted = values.sorted() + return sorted[sorted.size / 2] + } + + /** Corner background patches are sampled at this size on the grid. */ + private const val PATCH = 6 + private const val MIN_GRID = 12 + + /** A grid line counts as content when ≥5% of its samples differ from paper. */ + private const val CONTENT_LINE_SHARE = 5 + + /** Luminance delta that separates printed content from paper/scan noise. */ + private const val LUMINANCE_TOLERANCE = 28 +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCrop.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCrop.kt new file mode 100644 index 000000000..1619ecb5b --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCrop.kt @@ -0,0 +1,132 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +/** + * Per-side document margin crop ("обрезка пустых полей"). + * + * All values are fractions of the page size (0..[MAX_SIDE_FRACTION]) and are + * stored per side so PDF/DJVU pages with asymmetric scans can be cropped + * precisely. Pure Kotlin: no Android dependencies, unit-testable. + */ +data class ReaderMarginCrop( + val enabled: Boolean = false, + val left: Float = 0f, + val top: Float = 0f, + val right: Float = 0f, + val bottom: Float = 0f, + val symmetric: Boolean = true, + val showWarning: Boolean = true +) { + /** Crop is visually active only when enabled and at least one side is non-zero. */ + val hasVisibleCrop: Boolean + get() = left > 0f || top > 0f || right > 0f || bottom > 0f + + val isActive: Boolean + get() = enabled && hasVisibleCrop + + fun clamped(): ReaderMarginCrop = copy( + left = coerceSide(left), + top = coerceSide(top), + right = coerceSide(right), + bottom = coerceSide(bottom) + ) + + /** + * Enforces the symmetric invariant (left == right, top == bottom). + * Keeps the larger of each pair so enabling symmetric never silently + * reduces an already configured crop. + */ + fun withSymmetricEnforced(): ReaderMarginCrop { + val base = clamped() + if (!base.symmetric) return base + return base.copy( + left = maxOf(base.left, base.right), + right = maxOf(base.left, base.right), + top = maxOf(base.top, base.bottom), + bottom = maxOf(base.top, base.bottom) + ) + } + + /** Averages each axis pair; used when the user turns symmetric mode on. */ + fun withPairsAveraged(): ReaderMarginCrop { + val base = clamped() + val horizontal = (base.left + base.right) / 2f + val vertical = (base.top + base.bottom) / 2f + return base.copy( + left = horizontal, + right = horizontal, + top = vertical, + bottom = vertical + ) + } + + /** + * Sets one side. In symmetric mode the opposite side of the same axis + * follows the new value. + */ + fun withSide(side: ReaderMarginCropSide, value: Float): ReaderMarginCrop { + val safe = coerceSide(value) + val base = clamped() + return when (side) { + ReaderMarginCropSide.LEFT -> + if (base.symmetric) base.copy(left = safe, right = safe) else base.copy(left = safe) + ReaderMarginCropSide.RIGHT -> + if (base.symmetric) base.copy(left = safe, right = safe) else base.copy(right = safe) + ReaderMarginCropSide.TOP -> + if (base.symmetric) base.copy(top = safe, bottom = safe) else base.copy(top = safe) + ReaderMarginCropSide.BOTTOM -> + if (base.symmetric) base.copy(top = safe, bottom = safe) else base.copy(bottom = safe) + } + // NOTE: `enabled` is intentionally untouched here — the enable toggle is + // a separate control (matches the reference dialog where sides can be + // tuned while crop is disabled). + } + + /** Seeds per-side values from the legacy symmetric H/V preferences. */ + fun seededFromLegacy(horizontal: Float, vertical: Float): ReaderMarginCrop { + val h = coerceSide(horizontal) + val v = coerceSide(vertical) + return copy(left = h, right = h, top = v, bottom = v) + } + + companion object { + /** Hard cap for one side, shared with the rendering crop model. */ + const val MAX_SIDE_FRACTION = 0.22f + + fun coerceSide(value: Float): Float = value.coerceIn(0f, MAX_SIDE_FRACTION) + + /** Default disabled state used when no preferences exist yet. */ + val Default = ReaderMarginCrop() + } +} + +enum class ReaderMarginCropSide(val storedValue: String) { + LEFT("left"), + TOP("top"), + RIGHT("right"), + BOTTOM("bottom"); + + companion object { + fun fromStored(value: String?): ReaderMarginCropSide? = + entries.firstOrNull { it.storedValue.equals(value?.trim(), ignoreCase = true) } + } +} + +/** + * The crop sides that are actually applied to a rendered page: zeros when the + * format does not support cropping or the user disabled the feature. + */ +data class ReaderMarginCropSides( + val left: Float = 0f, + val top: Float = 0f, + val right: Float = 0f, + val bottom: Float = 0f +) { + val isZero: Boolean + get() = left <= 0f && top <= 0f && right <= 0f && bottom <= 0f +} + +fun ReaderMarginCrop.effectiveSides(supported: Boolean): ReaderMarginCropSides { + val base = clamped() + if (!supported || !base.isActive) return ReaderMarginCropSides() + return ReaderMarginCropSides(left = base.left, top = base.top, right = base.right, bottom = base.bottom) +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt index 168c96ea5..a134b54d8 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/domain/progress/EpubSectionPageCountStore.kt @@ -10,20 +10,25 @@ package io.leostrange.mrcomic.feature.reader.domain.progress internal class EpubSectionPageCountStore { private val lock = Any() private val counts = mutableMapOf() + private var sessionEstimate: Int? = null fun reset() { synchronized(lock) { counts.clear() + sessionEstimate = null } } fun recordAndSnapshot(sectionIndex: Int, pageCount: Int): Map = synchronized(lock) { if (sectionIndex >= 0 && pageCount > 0) { counts[sectionIndex] = pageCount + if (sessionEstimate == null) sessionEstimate = pageCount } sortedSnapshot() } + fun stableEstimate(): Int? = synchronized(lock) { sessionEstimate } + fun snapshot(): Map = synchronized(lock) { sortedSnapshot() } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt index 3164947e5..89ab948f3 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculator.kt @@ -21,12 +21,18 @@ internal object EpubProgressCalculator { * The fractional average is deliberately applied before conversion to Int so the * persisted total stays consistent with the previous reader progress calculation. */ - fun estimatedTotalPages(sectionPageCounts: Map, totalSections: Int): Int { + fun estimatedTotalPages( + sectionPageCounts: Map, + totalSections: Int, + stableEstimateOverride: Int? = null + ): Int { if (sectionPageCounts.isEmpty()) return 0 val orderedCounts = orderedSectionPageCounts(sectionPageCounts) val visitedPages = orderedCounts.values.sum() val visitedSections = orderedCounts.size if (totalSections <= visitedSections) return visitedPages + val estimate = stableEstimateOverride?.coerceAtLeast(1) + if (estimate != null) return estimate * totalSections return visitedPages + stableEstimate(orderedCounts) * (totalSections - visitedSections) } @@ -47,33 +53,56 @@ internal object EpubProgressCalculator { sectionPageCounts: Map, sectionIndex: Int, sectionPageIndex: Int, - totalSections: Int = 0 + totalSections: Int = 0, + stableEstimateOverride: Int? = null ): AccumulatedProgress { if (sectionPageCounts.isEmpty()) return AccumulatedProgress(0, 0) val orderedCounts = orderedSectionPageCounts(sectionPageCounts) val safePageIndex = sectionPageIndex.coerceAtLeast(0) val visitedTotal = orderedCounts.values.sum() - val stableEstimate = stableEstimate(orderedCounts) + val stableEstimate = stableEstimateOverride?.coerceAtLeast(1) + ?: stableEstimate(orderedCounts) var current = 0 for (index in 0 until sectionIndex) { current += orderedCounts[index] ?: stableEstimate } current += safePageIndex + // The effective section count must be at least large enough to cover the + // section we are currently in, even when totalBookSections is still + // provisional (deferred page-count not yet resolved). Without this floor, + // the total can be smaller than current, causing the progress bar to + // show 100% (or even a "page N / N" where N < current after coercion). + val effectiveTotalSections = totalSections.coerceAtLeast(sectionIndex + 1) // Estimate total using the stable baseline for unvisited sections. // This prevents the "floating total" where progress jumps backwards // when a new section loads with more pages than the running average. - val total = if (totalSections > orderedCounts.size) { - val unvisitedSections = totalSections - orderedCounts.size - visitedTotal + stableEstimate * unvisitedSections + val total = if (effectiveTotalSections > orderedCounts.size) { + if (stableEstimateOverride != null) { + stableEstimate * effectiveTotalSections + } else { + val unvisitedSections = effectiveTotalSections - orderedCounts.size + visitedTotal + stableEstimate * unvisitedSections + } } else { visitedTotal } + val isResolved = effectiveTotalSections > 0 && orderedCounts.size >= effectiveTotalSections return AccumulatedProgress( accumulatedTotalPages = total, - accumulatedCurrentPage = current.coerceAtMost(total) + accumulatedCurrentPage = current.coerceAtMost(total), + isResolved = isResolved ) } + /** + * Returns true if all spine sections have measured visual page counts. + */ + fun isResolved(sectionPageCounts: Map, totalSections: Int): Boolean { + if (totalSections <= 0 || sectionPageCounts.isEmpty()) return false + val orderedCounts = orderedSectionPageCounts(sectionPageCounts) + return orderedCounts.size >= totalSections + } + /** * Accumulated absolute page for progress persistence. Sums the visual page counts of * all sections preceding [sectionIndex], then adds the in-section offset. Unvisited @@ -112,7 +141,8 @@ internal object EpubProgressCalculator { data class AccumulatedProgress( val accumulatedTotalPages: Int, - val accumulatedCurrentPage: Int + val accumulatedCurrentPage: Int, + val isResolved: Boolean = false ) /** diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt index c1245277f..f554ce943 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/HtmlPageView.kt @@ -3,6 +3,7 @@ package io.leostrange.mrcomic.feature.reader.ui import android.os.Build import android.webkit.WebSettings import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -13,8 +14,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.webkit.WebViewAssetLoader +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.core.ui.theme.style @@ -161,6 +165,8 @@ internal fun HtmlPageView( FreeScrollPositionHolder(null) } val runtimeOwner = remember { ReaderWebViewRuntimeOwner() } + val lifecycleOwner = LocalLifecycleOwner.current + val webViewReference = remember { mutableStateOf(null) } val loadController = runtimeOwner.loadController val pageSource = rememberReaderHtmlPageSource( controller = loadController, @@ -194,6 +200,22 @@ internal fun HtmlPageView( val currentCharOffset = rememberUpdatedState(sectionCharacterOffset) val onSelectionModeChange = rememberUpdatedState(onSelectionActionModeChange) + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + webViewReference.value?.let { webView -> + webView.onResume() + webView.resumeTimers() + webView.invalidate() + webView.requestLayout() + webView.postDelayed({ webView.verifyVisibleContentOrFallback() }, 120L) + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + // Auto-scroll state — must be before AndroidView so the factory can capture it. val autoScrollPaused = remember { mutableStateOf(false) } val autoScrollScrollLambda = remember { mutableStateOf<((Int) -> Unit)?>(null) } @@ -230,6 +252,7 @@ internal fun HtmlPageView( factory = { ctx -> ReaderWebView(ctx).apply { val readerWebView = this + webViewReference.value = this // ARC-11 slice 2b: bridge ReaderWebView.markLoadCommitted's token // into the load controller so shouldRestoreScroll() follows the // WebView's own commit lifecycle. @@ -333,6 +356,7 @@ internal fun HtmlPageView( } }, update = { webView -> + webView.setBackgroundColor(bgColor) webView.pagedModeScrollLock = pagedMode webView.onFreeScrollPositionChanged = if (pagedMode) { null @@ -434,8 +458,12 @@ internal fun HtmlPageView( return false; })(); """.trimIndent() - webView.evaluateJavascript(script) { _ -> - onConsumeWebtoonSectionState.value() + webView.evaluateJavascript(script) { rawValue -> + // Keep the cursor pending until the target section exists and + // the runtime confirms that it actually scrolled to it. + if (rawValue?.trim('"') == "true") { + onConsumeWebtoonSectionState.value() + } } } val viewportWidthPx = webView.readerCssViewportWidthPxOrNull() @@ -457,6 +485,7 @@ internal fun HtmlPageView( webView.applyHighlightsIfChanged(highlightsJs) }, onRelease = { webView -> + if (webViewReference.value === webView) webViewReference.value = null autoScrollScrollLambda.value = null if (!webView.pagedModeScrollLock) { webView.currentFreeScrollRestoreTarget()?.let { position -> diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt index aa3571dfa..0abe07ae3 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAudioSheet.kt @@ -517,7 +517,13 @@ private fun ReaderAudioSliderRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text(text = title, style = MaterialTheme.typography.bodySmall) + Text( + text = title, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) Text( text = valueText, style = MaterialTheme.typography.labelSmall, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt index 2c32eadd0..1fca547f4 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollChromeControls.kt @@ -2,33 +2,41 @@ package io.leostrange.mrcomic.feature.reader.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.BorderStroke import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode +import io.leostrange.mrcomic.core.ui.designsystem.MrComicCornerScale +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import kotlin.math.roundToInt /** @@ -99,17 +107,14 @@ internal fun ReaderAutoScrollChromeControls( onSpeedCommit: (Float) -> Unit, modifier: Modifier = Modifier, ) { + // Keep the chrome compact on every fresh reader open. The detailed controls + // are still available behind the explicit expand action. + var expanded by rememberSaveable { mutableStateOf(false) } var draftSpeed by remember(speed) { mutableFloatStateOf(ReaderAutoScrollPrecision.normalize(speed)) } val normalizedProgress = countdownProgress.coerceIn(0f, 1f) val canCountDown = autoScrollEnabled && !isTemporarilyPaused && readingMode != ReadingMode.WEBTOON - val buttonDescription = if (autoScrollEnabled) { - "Остановить автопрокрутку" - } else { - "Запустить автопрокрутку" - } - Column( modifier = modifier .fillMaxWidth() @@ -121,34 +126,35 @@ internal fun ReaderAutoScrollChromeControls( verticalAlignment = Alignment.CenterVertically, ) { IconButton( - onClick = onToggleAutoScroll, - modifier = Modifier.semantics { contentDescription = buttonDescription }, + onClick = { expanded = !expanded }, + modifier = Modifier.semantics { + contentDescription = if (expanded) { + "Свернуть настройки автопрокрутки" + } else { + "Развернуть настройки автопрокрутки" + } + }, ) { Icon( - imageVector = if (autoScrollEnabled) Icons.Filled.Pause else Icons.Filled.PlayArrow, + imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = null, - tint = if (autoScrollEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } Column(modifier = Modifier.weight(1f)) { Text( text = "Автопрокрутка", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, + style = MrComicType.h3, ) Text( text = when { isTemporarilyPaused -> "Временно приостановлена" autoScrollEnabled && readingMode == ReadingMode.WEBTOON -> "Плавная прокрутка ленты" - autoScrollEnabled -> "До следующей страницы" + autoScrollEnabled -> "Автопереход по страницам" else -> "Выключена" }, - style = MaterialTheme.typography.labelSmall, + style = MrComicType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -156,11 +162,63 @@ internal fun ReaderAutoScrollChromeControls( Spacer(Modifier.width(8.dp)) Text( text = ReaderAutoScrollPrecision.valueLabel(draftSpeed, readingMode), - style = MaterialTheme.typography.labelMedium, + style = MrComicType.meta, color = MaterialTheme.colorScheme.primary, ) } + if (expanded) { + + Text( + text = if (readingMode == ReadingMode.WEBTOON) { + "Скорость плавной ленты" + } else { + "Задержка между страницами" + }, + style = MrComicType.meta, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(30f, 80f, 180f).forEach { preset -> + val selected = ReaderAutoScrollPrecision.normalize(draftSpeed) == preset + OutlinedButton( + onClick = { + val normalized = ReaderAutoScrollPrecision.normalize(preset) + draftSpeed = normalized + onSpeedPreview(normalized) + onSpeedCommit(normalized) + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(MrComicCornerScale.md), + border = BorderStroke( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + ), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + ) { + Text( + text = when (preset) { + 30f -> "Медленно" + 80f -> "Обычно" + else -> "Быстро" + }, + style = MrComicType.button, + maxLines = 1, + ) + } + } + } + Slider( value = draftSpeed, onValueChange = { rawSpeed -> @@ -207,5 +265,6 @@ internal fun ReaderAutoScrollChromeControls( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt index a25b70881..48962f4d7 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollRuntime.kt @@ -29,15 +29,7 @@ internal fun readerAutoScrollDockHeightDp( containerKind: ReaderContainerKind, chromeHidden: Boolean, enabled: Boolean, -): Int = if ( - chromeHidden && enabled && - (containerKind == ReaderContainerKind.TEXT_PAGE || - containerKind == ReaderContainerKind.RASTER_PAGE) -) { - 72 -} else { - 0 -} +): Int = 0 internal fun requestReaderAutoPageAdvance( containerKind: ReaderContainerKind, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt index 3f5f8d5be..6a38d6a3c 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningController.kt @@ -6,6 +6,7 @@ import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsEvent import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsTracker import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ReadingMode +import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderNavigationProgressSource import io.leostrange.mrcomic.engine.api.BookSession import io.leostrange.mrcomic.engine.api.FormatReader import io.leostrange.mrcomic.engine.api.RenderDeviceTier @@ -145,6 +146,8 @@ internal class ReaderBookOpeningController( loadInitialPages(comic, prepared, activeReader, config) scheduleDeferredPageCountIfNeeded(comic, activeReader, prepared, config, requestToken) schedulePostOpenTasks(comic, config.startPage, config.initialPages) + // BUG-READER-03: start periodic position snapshots to survive process kills. + progressController.startPeriodicPositionSave() sessionLifecycleCoordinator.markReadyAfterBeginOpen() } catch (e: CancellationException) { sessionLifecycleCoordinator.reset() @@ -269,7 +272,26 @@ internal class ReaderBookOpeningController( val requestedPage = pendingRequestedPage val shouldDeferCount = prepared.deferPageCount val initialPages = if (shouldDeferCount) 1 else prepared.pages.coerceAtLeast(1) - val requestedStartPage = requestedPage ?: restoredPosition?.engineSectionIndex ?: comic.currentPage + // BUG-READER-03: legacy records without readerPositionJson fall back to comic.currentPage. + // That raw int is a visual-page index from the old persistence, not a section index. + // Wrapping it in a synthetic ReaderPosition and routing through planReaderPositionRestore + // ensures the structured restore path (section → sub-page → anchor) is always used, + // even for records that predate the structured schema. + val effectivePosition = restoredPosition ?: comic.currentPage.takeIf { it > 0 }?.let { + ReaderPosition(engineSectionIndex = it, mode = openingMode) + } + val requestedStartPage = requestedPage ?: effectivePosition?.let { pos -> + if (initialPages > 0) { + planReaderPositionRestore( + position = pos, + openingMode = openingMode, + resolvedTotalPages = initialPages, + normalizePage = { page, mode, total -> navigationController.normalizePageForMode(page, mode, total) } + )?.startPage + } else { + pos.engineSectionIndex + } + } ?: 0 val startPage = navigationController.normalizePageForMode(requestedStartPage, openingMode, initialPages) pendingRequestedPage = null progressController.lastPersistedProgress = PersistedProgressMarker( @@ -287,7 +309,9 @@ internal class ReaderBookOpeningController( startPage = startPage, requestedStartPage = requestedStartPage, requestedPage = requestedPage, - restoredPosition = restoredPosition + // BUG-READER-03: pass the synthetic position so the deferred page-count + // resolution path also uses the structured restore for legacy records. + restoredPosition = effectivePosition ) } @@ -492,24 +516,34 @@ internal class ReaderBookOpeningController( normalizePage = { page, mode, total -> navigationController.normalizePageForMode(page, mode, total) } ) } - val resolvedStartPage = restorePlan?.startPage ?: normalizedStartPage + val restoredStartPage = restorePlan?.startPage ?: normalizedStartPage val restoresWebtoon = openingMode == ReadingMode.WEBTOON + // T6 FIX: Only apply the restored start page if the user hasn't navigated away + // from it. During the deferred resolution delay, the user may have turned pages. + // Overwriting their current position causes the "page flip rolls back" bug. + val currentPage = _uiState.value.currentPage + val userHasNavigated = currentPage != restoredStartPage && currentPage != 0 + val resolvedStartPage = if (userHasNavigated) currentPage else restoredStartPage _uiState.update { it.copy( totalPages = realPages, currentPage = resolvedStartPage, isLoading = false, - sectionCurrentPage = if (restoresWebtoon) { - 0 + sectionCurrentPage = if (userHasNavigated || restoresWebtoon) { + if (restoresWebtoon) 0 else it.sectionCurrentPage } else { restorePlan?.sectionCurrentPage ?: it.sectionCurrentPage }, - sectionCharacterOffset = if (restoresWebtoon) { - 0 + sectionCharacterOffset = if (userHasNavigated || restoresWebtoon) { + if (restoresWebtoon) 0 else it.sectionCharacterOffset } else { restorePlan?.characterOffset ?: it.sectionCharacterOffset }, - pendingScrollToAnchor = restorePlan?.domAnchor ?: it.pendingScrollToAnchor, + pendingScrollToAnchor = if (userHasNavigated) { + it.pendingScrollToAnchor + } else { + restorePlan?.domAnchor ?: it.pendingScrollToAnchor + }, pendingWebtoonSectionIndex = if (restoresWebtoon) { restorePlan?.webtoonSectionIndex ?: it.pendingWebtoonSectionIndex } else { @@ -540,6 +574,15 @@ internal class ReaderBookOpeningController( } } bookmarkController.loadBookmarks(comic.id, realPages) + // BUG-READER-01: persist the resolved page count immediately so the library card, + // progress bar, and next-open restore all see the correct total — even if the user + // closes the reader without turning another page. Without this, pageCount stays 0 + // in the database until the next saveProgress call, causing displayReadingProgress() + // to fall back to the stored (possibly stale) readingProgress value. + progressController.saveProgress( + page = resolvedStartPage, + progressSource = ReaderNavigationProgressSource.READING + ) } private fun schedulePostOpenTasks(comic: Comic, startPage: Int, initialPages: Int) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt index 6aeaf6740..d135d5221 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBottomSheets.kt @@ -163,7 +163,7 @@ internal fun ReaderBottomSheets( ) } - // ── Настройки текста (ModalBottomSheet) ──────────────────────────────────── + // ── Настройки ридера (Control center: STYLE/READING/SERVICES tabs) ────── if (uiState.showTextSettings) { ReaderControlCenterSheet( uiState = uiState, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt index db410f208..69385fac6 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBars.kt @@ -50,6 +50,7 @@ internal fun BoxScope.ReaderChromeBars( onDismissFootnote: () -> Unit, onExpandFootnote: () -> Unit, onCollapseFootnote: () -> Unit, + onToggleMarginCrop: () -> Unit = {}, ) { ReaderTopChromeBar( uiState = uiState, @@ -76,6 +77,7 @@ internal fun BoxScope.ReaderChromeBars( onBrightnessChange = onBrightnessChange, onAutoScrollSpeedPreview = onAutoScrollSpeedPreview, onAutoScrollSpeedCommit = onAutoScrollSpeedCommit, + onToggleMarginCrop = onToggleMarginCrop, ) ReaderBottomChromePanel( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt index aef13dced..8ed398531 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeBottomPanel.kt @@ -38,13 +38,13 @@ fun ReaderExpandedBottomPanel( ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) - val showReadingPresets = uiState.currentHtmlContent != null + val showReadingPresets = true val useCompactLandscapeImagePanel = isLandscape && !showReadingPresets if (useCompactLandscapeImagePanel) { ReaderCompactLandscapeBottomPanel( - currentPage = uiState.currentPage, - totalPages = uiState.totalPages, + currentPage = uiState.effectiveCurrentPage, + totalPages = uiState.effectiveTotalPages, readingMode = uiState.readingMode, bookmarked = uiState.currentPage in uiState.bookmarkedPages, onToggleBookmark = onToggleBookmark, @@ -63,13 +63,23 @@ fun ReaderExpandedBottomPanel( modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - ReaderPanelChip( - selected = uiState.currentPage in uiState.bookmarkedPages, - onClick = onToggleBookmark, - label = { - Text(if (uiState.currentPage in uiState.bookmarkedPages) strings.readerBookmarked else strings.readerBookmark) - } - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + ReaderPanelChip( + selected = uiState.currentPage in uiState.bookmarkedPages, + onClick = onToggleBookmark, + label = { Text(if (uiState.currentPage in uiState.bookmarkedPages) strings.readerBookmarked else strings.readerBookmark) } + ) + ReaderPanelChip( + selected = uiState.readingMode != ReadingMode.WEBTOON, + onClick = { onReadingModeChange(ReadingMode.PAGE_LTR) }, + label = { Text(strings.readerPages) } + ) + ReaderPanelChip( + selected = uiState.readingMode == ReadingMode.WEBTOON, + onClick = { onReadingModeChange(ReadingMode.WEBTOON) }, + label = { Text(strings.readingModeWebtoon) } + ) + } if (showReadingPresets) { Text( text = strings.readerReadingPresets, @@ -108,8 +118,13 @@ fun ReaderExpandedBottomPanel( ?.title, epubAccumulatedTotalPages = uiState.epubAccumulatedTotalPages, epubAccumulatedCurrentPage = uiState.epubAccumulatedCurrentPage, + isTextPaginationResolved = uiState.isTextPaginationResolved, + isTextWebtoon = uiState.readerContainerKind == ReaderContainerKind.TEXT_WEBTOON, + freeScrollProgression = uiState.freeScrollProgression, + rasterWebtoonScrollProgression = uiState.rasterWebtoonScrollProgression, onReadingModeChange = onReadingModeChange, - onPageChange = onPageChange + onPageChange = onPageChange, + showReadingModeControls = false ) } } @@ -172,7 +187,7 @@ private fun ReaderCompactLandscapeBottomPanel( Spacer(Modifier.weight(1f)) Text( - text = "${currentPage + 1} / $totalPages", + text = if (totalPages > 1) "${currentPage.coerceIn(0, totalPages - 1) + 1} / $totalPages" else "1 / …", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt index 2512b9fb9..ae68de881 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeButton.kt @@ -7,7 +7,8 @@ internal enum class ReaderChromeButton(val storedValue: String) { DIRECTION("direction"), TRANSLATE("translate"), BRIGHTNESS("brightness"), - AUTO_SCROLL("auto_scroll"); + AUTO_SCROLL("auto_scroll"), + CROP("crop"); companion object { val defaultOrder: List = listOf( @@ -17,7 +18,8 @@ internal enum class ReaderChromeButton(val storedValue: String) { DIRECTION, TRANSLATE, BRIGHTNESS, - AUTO_SCROLL + AUTO_SCROLL, + CROP ) val defaultStoredOrder: String = defaultOrder.joinToString(",") { it.storedValue } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt index b6125e394..3a472be92 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeComponents.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Crop import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -46,6 +47,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.library.RootChromeTopBarHost +import io.leostrange.mrcomic.core.ui.designsystem.MrComicCornerScale +import io.leostrange.mrcomic.core.ui.designsystem.MrComicType import io.leostrange.mrcomic.core.ui.locale.LocalStrings @OptIn(ExperimentalMaterial3Api::class) @@ -63,7 +66,7 @@ fun ReaderMinimalBar( text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium + style = MrComicType.h3 ) }, navigationIcon = { @@ -92,11 +95,13 @@ private fun ReaderChromeIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, buttonSize: Dp = 42.dp, + enabled: Boolean = true, content: @Composable () -> Unit ) { IconButton( onClick = onClick, - modifier = modifier.size(buttonSize) + modifier = modifier.size(buttonSize), + enabled = enabled ) { content() } @@ -113,9 +118,9 @@ internal fun ReaderPanelChip( selected = selected, onClick = onClick, modifier = modifier.heightIn(min = 38.dp), - shape = RoundedCornerShape(999.dp), + shape = RoundedCornerShape(MrComicCornerScale.lg), colors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, labelColor = MaterialTheme.colorScheme.onSurface, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer @@ -143,6 +148,8 @@ fun ReaderExpandedBar( showTranslateIcon: Boolean = true, showBrightnessIcon: Boolean = true, showAutoScrollIcon: Boolean = true, + showCropIcon: Boolean = false, + marginCropAvailable: Boolean = false, autoScrollActive: Boolean = false, onNavigateBack: () -> Unit, onToggleToc: () -> Unit, @@ -151,7 +158,8 @@ fun ReaderExpandedBar( onRequestOcr: () -> Unit, onToggleBrightness: () -> Unit, onToggleTtsControls: () -> Unit = {}, - onAutoScrollToggle: () -> Unit = {} + onAutoScrollToggle: () -> Unit = {}, + onToggleMarginCrop: () -> Unit = {} ) { val strings = LocalStrings.current val chromeIconTint = MaterialTheme.colorScheme.onSurface @@ -198,6 +206,8 @@ fun ReaderExpandedBar( showTranslateIcon = showTranslateIcon, showBrightnessIcon = showBrightnessIcon, showAutoScrollIcon = showAutoScrollIcon, + showCropIcon = showCropIcon, + marginCropAvailable = marginCropAvailable, autoScrollActive = autoScrollActive, chromeIconTint = chromeIconTint, onToggleToc = onToggleToc, @@ -206,12 +216,25 @@ fun ReaderExpandedBar( onRequestOcr = onRequestOcr, onToggleBrightness = onToggleBrightness, onToggleTtsControls = onToggleTtsControls, - onAutoScrollToggle = onAutoScrollToggle + onAutoScrollToggle = onAutoScrollToggle, + onToggleMarginCrop = onToggleMarginCrop ) } } Spacer(Modifier.width(44.dp)) } + if (title.isNotBlank()) { + Text( + text = title, + style = MrComicType.meta, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + ) + } } @Composable @@ -231,6 +254,8 @@ private fun ReaderExpandedActionButtons( showTranslateIcon: Boolean, showBrightnessIcon: Boolean, showAutoScrollIcon: Boolean = true, + showCropIcon: Boolean = false, + marginCropAvailable: Boolean = false, autoScrollActive: Boolean = false, chromeIconTint: Color, onToggleToc: () -> Unit, @@ -239,7 +264,8 @@ private fun ReaderExpandedActionButtons( onRequestOcr: () -> Unit, onToggleBrightness: () -> Unit, onToggleTtsControls: () -> Unit = {}, - onAutoScrollToggle: () -> Unit = {} + onAutoScrollToggle: () -> Unit = {}, + onToggleMarginCrop: () -> Unit = {} ) { val strings = LocalStrings.current val readerText = readerUiText(strings.languageCode) @@ -379,6 +405,39 @@ private fun ReaderExpandedActionButtons( ) ) } + + ReaderChromeButton.CROP -> + // Visible for raster readers, locked (disabled) for comics/manga; + // fully active only for document formats (PDF / DjVu). + if (showCropIcon) { + add( + ReaderChromeActionSpec( + key = action.storedValue, + content = { buttonSize -> + ReaderChromeIconButton( + onClick = onToggleMarginCrop, + buttonSize = buttonSize, + enabled = marginCropAvailable + ) { + val cropHint = if (marginCropAvailable) { + readerMarginCropDialogTitle(strings.languageCode) + } else { + readerMarginCropLockedHint(strings.languageCode) + } + Icon( + Icons.Default.Crop, + contentDescription = cropHint, + tint = if (marginCropAvailable) { + chromeIconTint + } else { + chromeIconTint.copy(alpha = LockedIconAlpha) + } + ) + } + } + ) + ) + } } } } @@ -405,6 +464,9 @@ private data class ReaderChromeActionSpec( val content: @Composable (Dp) -> Unit ) +/** Dimmed tint for chrome icons that are visible but locked. */ +private const val LockedIconAlpha = 0.38f + @Composable fun ReaderBrightnessRow( brightness: Float, @@ -471,7 +533,7 @@ fun ReaderProgressPill( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = "${currentPage + 1} / $totalPages", + text = if (totalPages > 1) "${currentPage.coerceIn(0, totalPages - 1) + 1} / $totalPages" else "1 / …", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt index 157df3a03..7b5eaf1fa 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderChromeOverlays.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode +import io.leostrange.mrcomic.core.model.supportsDocumentMarginCrop import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.feature.reader.domain.enums.FootnotePresentation import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderChromeState @@ -206,7 +207,8 @@ internal fun BoxScope.ReaderTopChromeBar( onAutoScrollToggle: () -> Unit, onBrightnessChange: (Float) -> Unit, onAutoScrollSpeedPreview: (Float) -> Unit, - onAutoScrollSpeedCommit: (Float) -> Unit + onAutoScrollSpeedCommit: (Float) -> Unit, + onToggleMarginCrop: () -> Unit = {} ) { if (showHeaderFooterOverlay && headerOverlayLine.hasVisibleContent) { Surface( @@ -315,18 +317,21 @@ internal fun BoxScope.ReaderTopChromeBar( uiState.readingMode == ReadingMode.PAGE_RTL, directionShortcutActive = directionShortcutActive, showBrightnessRow = showBrightnessRow, - useDirectActions = isTextReader, + useDirectActions = true, chromeIconOrder = uiState.chromeIconOrder, showTocIcon = readerShouldShowTocChromeButton( isTextReader = isTextReader, buttonEnabled = uiState.chromeShowTocIcon, ), - showTextSettingsIcon = uiState.chromeShowStyleIcon, + showTextSettingsIcon = uiState.chromeShowStyleIcon || !isTextReader, showAudioIcon = uiState.chromeShowAudioIcon && isTextReader, showDirectionIcon = uiState.chromeShowDirectionIcon && (uiState.readingMode == ReadingMode.PAGE_LTR || uiState.readingMode == ReadingMode.PAGE_RTL), showTranslateIcon = uiState.chromeShowTranslateIcon, showBrightnessIcon = uiState.chromeShowBrightnessIcon, showAutoScrollIcon = uiState.chromeShowAutoScrollIcon, + // Margin crop: raster readers only; document formats (PDF/DjVu) unlock it. + showCropIcon = uiState.chromeShowCropIcon && !isTextReader, + marginCropAvailable = uiState.comic?.format?.supportsDocumentMarginCrop() == true, autoScrollActive = uiState.autoScrollEnabled, onNavigateBack = onNavigateBack, onToggleToc = onToggleToc, @@ -335,7 +340,8 @@ internal fun BoxScope.ReaderTopChromeBar( onRequestOcr = onRequestOcr, onToggleBrightness = onToggleBrightness, onToggleTtsControls = onToggleTtsControls, - onAutoScrollToggle = onAutoScrollToggle + onAutoScrollToggle = onAutoScrollToggle, + onToggleMarginCrop = onToggleMarginCrop ) if (showBrightnessRow) { ReaderBrightnessRow( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt index fc3ce9b56..4055bd6db 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContainerHost.kt @@ -13,6 +13,7 @@ import io.leostrange.mrcomic.core.model.ReaderTapZoneLayout import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.feature.reader.ui.components.PageView +import io.leostrange.mrcomic.feature.reader.ui.components.ReaderImageCrop import io.leostrange.mrcomic.feature.reader.ui.components.ReadiumEpubView import io.leostrange.mrcomic.feature.reader.ui.components.TextContainer import io.leostrange.mrcomic.feature.reader.ui.components.WebtoonView @@ -27,8 +28,7 @@ internal fun ReaderContainerHost( readerText: ReaderUiText, languageCode: String, tapZoneLayout: ReaderTapZoneLayout, - effectiveMarginCropHorizontal: Float, - effectiveMarginCropVertical: Float, + effectiveMarginCrop: ReaderImageCrop, effectivePageImageScaleMode: String, textReaderModifier: Modifier, imageReaderModifier: Modifier, @@ -221,8 +221,10 @@ internal fun ReaderContainerHost( viewModel = viewModel, uiState = uiState, imageScaleMode = uiState.imageScaleMode, - marginCropHorizontal = effectiveMarginCropHorizontal, - marginCropVertical = effectiveMarginCropVertical, + marginCropLeft = effectiveMarginCrop.normalizedLeft, + marginCropTop = effectiveMarginCrop.normalizedTop, + marginCropRight = effectiveMarginCrop.normalizedRight, + marginCropBottom = effectiveMarginCrop.normalizedBottom, onLeftTap = {}, onRightTap = {}, onCenterTap = { handleTapZoneAction(tapZoneLayout.center) }, @@ -234,8 +236,10 @@ internal fun ReaderContainerHost( viewModel = viewModel, uiState = uiState, imageScaleMode = effectivePageImageScaleMode, - marginCropHorizontal = effectiveMarginCropHorizontal, - marginCropVertical = effectiveMarginCropVertical, + marginCropLeft = effectiveMarginCrop.normalizedLeft, + marginCropTop = effectiveMarginCrop.normalizedTop, + marginCropRight = effectiveMarginCrop.normalizedRight, + marginCropBottom = effectiveMarginCrop.normalizedBottom, onLeftTap = { handleTapZoneAction(tapZoneLayout.left) }, onRightTap = { handleTapZoneAction(tapZoneLayout.right) }, onCenterTap = { handleTapZoneAction(tapZoneLayout.center) }, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt index e44b0259b..907ab6ff1 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPathResolver.kt @@ -156,6 +156,10 @@ internal object ReaderContentPathResolver { } fun hasReadAccess(context: Context, path: String): Boolean { + if (!path.startsWith("content://")) { + // Fast path for local files: avoid opening an InputStream just to test permission. + return isLocalFileReadable(path) + } return try { context.contentResolver.openInputStream(Uri.parse(path))?.use { true } ?: false } catch (_: Exception) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt index 82ad65f2e..773837363 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicy.kt @@ -5,6 +5,51 @@ import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.model.isArchiveFormat import io.leostrange.mrcomic.core.model.isGraphicReaderFormat import io.leostrange.mrcomic.core.model.isTextReadingFormat +import io.leostrange.mrcomic.core.model.supportsDocumentMarginCrop +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop +import io.leostrange.mrcomic.feature.reader.ui.components.ReaderImageCrop + +/** Image margin controls are available only for raster content. */ +fun supportsImageMarginCrop( + containerKind: ReaderContainerKind, + format: ComicFormat? +): Boolean { + if (containerKind.isTextContainer()) return false + return format == ComicFormat.PDF || + format == ComicFormat.DJVU || + containerKind == ReaderContainerKind.RASTER_WEBTOON +} + +/** + * The margin-crop chrome button is fully active only for document formats + * (PDF / DjVu). Graphic formats (comics/manga) show the button locked, so + * document comics in PDF still get crop while CBR/CBZ do not pretend to. + */ +fun supportsDocumentMarginCropButton(format: ComicFormat?): Boolean = + format != null && format.supportsDocumentMarginCrop() + +/** + * Resolves the crop actually applied to rendered pages: zeros unless the + * container supports cropping and the user enabled the feature. + */ +internal fun resolveEffectiveImageMarginCrop( + containerKind: ReaderContainerKind, + format: ComicFormat?, + enabled: Boolean, + left: Float, + top: Float, + right: Float, + bottom: Float +): ReaderImageCrop { + val supported = supportsImageMarginCrop(containerKind, format) + if (!supported || !enabled) return ReaderImageCrop.None + return ReaderImageCrop( + leftFraction = left.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + topFraction = top.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + rightFraction = right.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + bottomFraction = bottom.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION) + ) +} enum class ReaderContainerKind { TEXT_PAGE, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt index 68dd0db46..c5746c330 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderControlCenterStrings.kt @@ -357,6 +357,13 @@ internal fun readerChromeButtonLabel( "ko" -> "자동 스크롤" else -> "Auto scroll" } + ReaderChromeButton.CROP -> when (language) { + "ru" -> "Обрезка полей" + "ja" -> "余白トリミング" + "zh" -> "页边裁剪" + "ko" -> "여백 자르기" + else -> "Margin crop" + } } internal fun readerChromeButtonVisible( @@ -370,6 +377,7 @@ internal fun readerChromeButtonVisible( ReaderChromeButton.TRANSLATE -> uiState.chromeShowTranslateIcon ReaderChromeButton.BRIGHTNESS -> uiState.chromeShowBrightnessIcon ReaderChromeButton.AUTO_SCROLL -> uiState.chromeShowAutoScrollIcon + ReaderChromeButton.CROP -> uiState.chromeShowCropIcon } internal fun readerTtsSleepTimerLabel(mode: ReaderTtsSleepTimerMode, language: String): String = when (mode) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt index 336b00dec..4af7059c0 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHeaderFooterUi.kt @@ -112,13 +112,14 @@ internal fun resolveReaderInfoOverlayLine( clockText: String, currentPage: Int, totalPages: Int, - readingMode: ReadingMode + readingMode: ReadingMode, + canonicalProgressPercent: Int? = null ): ReaderInfoOverlayLine { val visiblePages = resolveReaderVisiblePages(currentPage, totalPages, readingMode) return ReaderInfoOverlayLine( - start = resolveReaderInfoSlotValue(startSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages), - center = resolveReaderInfoSlotValue(centerSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages), - end = resolveReaderInfoSlotValue(endSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages) + start = resolveReaderInfoSlotValue(startSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages, canonicalProgressPercent), + center = resolveReaderInfoSlotValue(centerSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages, canonicalProgressPercent), + end = resolveReaderInfoSlotValue(endSlot, comicTitle, chapterTitle, clockText, visiblePages, totalPages, canonicalProgressPercent) ) } @@ -147,13 +148,15 @@ private fun resolveReaderInfoSlotValue( chapterTitle: String?, clockText: String, visiblePages: List, - totalPages: Int + totalPages: Int, + canonicalProgressPercent: Int? ): String = when (ReaderInfoSlot.fromStored(slot)) { ReaderInfoSlot.NONE -> "" ReaderInfoSlot.BOOK_TITLE -> comicTitle.orEmpty() ReaderInfoSlot.CHAPTER_TITLE -> chapterTitle.orEmpty() ReaderInfoSlot.TIME -> clockText - ReaderInfoSlot.PROGRESS -> resolveReaderProgressLabel(visiblePages, totalPages) + ReaderInfoSlot.PROGRESS -> canonicalProgressPercent?.coerceIn(0, 100)?.let { "$it%" } + ?: resolveReaderProgressLabel(visiblePages, totalPages) ReaderInfoSlot.PAGE -> resolveReaderPageLabel(visiblePages, totalPages) } @@ -162,7 +165,9 @@ private fun resolveReaderProgressLabel( totalPages: Int ): String { if (totalPages <= 0 || visiblePages.isEmpty()) return "" - val progress = (((visiblePages.last() + 1).toFloat() / totalPages.toFloat()) * 100f) + // Use the first visible page for progress calculation to match library display. + // For DUAL_PAGE mode, this shows progress for the left page, which is the canonical position. + val progress = (((visiblePages.first() + 1).toFloat() / totalPages.toFloat()) * 100f) .roundToInt() .coerceIn(0, 100) return "$progress%" diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt new file mode 100644 index 000000000..7ed7f8655 --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropDialog.kt @@ -0,0 +1,384 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import android.graphics.Bitmap +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Crop +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import io.leostrange.mrcomic.core.ui.locale.LocalStrings +import io.leostrange.mrcomic.feature.reader.domain.crop.MarginCropAutoDetector +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCropSide +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCropSides +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Minimal margin-crop dialog ("Обрезка пустых полей"). + * + * Opens over the reader with chrome hidden; every change is applied to the + * page live through [ReaderSettingsController] and persisted to DataStore. + */ +@Composable +internal fun ReaderMarginCropDialog( + uiState: ReaderUiState, + viewModel: ReaderViewModel, + onDismiss: () -> Unit +) { + val strings = LocalStrings.current + val language = strings.languageCode + val controller = viewModel.settingsController + val scope = rememberCoroutineScope() + var autoRunning by remember { mutableStateOf(false) } + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val layout = readerMarginCropLayout(isLandscape) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(layout.widthFraction) + .widthIn(max = if (isLandscape) 820.dp else 420.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), + tonalElevation = 3.dp + ) { + Column(modifier = Modifier.padding(horizontal = 18.dp, vertical = layout.verticalPaddingDp.dp)) { + // ── Header ──────────────────────────────────────────────── + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(34.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Crop, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(18.dp) + ) + } + Spacer(Modifier.width(12.dp)) + Text( + text = readerMarginCropDialogTitle(language), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = strings.back, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.height(6.dp)) + + // ── Enable ──────────────────────────────────────────────── + SwitchRow( + title = readerMarginCropEnable(language), + checked = uiState.marginCropEnabled, + onCheckedChange = controller::setMarginCropEnabled + ) + + // ── Presets ─────────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val preset = ReaderMarginCrop.coerceSide(0.05f) + PresetChip( + selected = autoRunning, + label = if (autoRunning) readerMarginCropAutoRunning(language) + else readerMarginCropPresetAuto(language), + onClick = { + if (autoRunning) return@PresetChip + val bitmap = viewModel.pageLoader.getPage(uiState.currentPage) + if (bitmap == null) { + controller.applyMarginCropSides(preset, preset, preset, preset) + } else { + autoRunning = true + scope.launch { + val sides = withContext(Dispatchers.Default) { + scanBitmapMargins(bitmap) + } + autoRunning = false + if (sides != null) { + controller.applyMarginCropSides( + sides.left, sides.top, sides.right, sides.bottom + ) + } + } + } + } + ) + listOf(0.05f, 0.10f, 0.15f).forEach { value -> + PresetChip( + selected = sidesEqualPreset(uiState, value), + label = "${(value * 100).toInt()}%", + onClick = { + controller.applyMarginCropSides(value, value, value, value) + } + ) + } + } + + // ── Sides ───────────────────────────────────────────────── + val sides = listOf( + Triple(readerMarginCropSideTop(language), uiState.marginCropTop, ReaderMarginCropSide.TOP), + Triple(readerMarginCropSideBottom(language), uiState.marginCropBottom, ReaderMarginCropSide.BOTTOM), + Triple(readerMarginCropSideLeft(language), uiState.marginCropLeft, ReaderMarginCropSide.LEFT), + Triple(readerMarginCropSideRight(language), uiState.marginCropRight, ReaderMarginCropSide.RIGHT), + ) + if (layout.sideColumns == 2) { + Row( + modifier = Modifier.alpha(if (uiState.marginCropEnabled) 1f else DisabledAlpha), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + listOf(sides.take(2), sides.drop(2)).forEach { columnSides -> + Column(modifier = Modifier.weight(1f)) { + columnSides.forEach { (title, value, side) -> + MarginCropSideRow(title, value, { controller.setMarginCropSide(side.storedValue, it) }) + } + } + } + } + } else { + Column(modifier = Modifier.alpha(if (uiState.marginCropEnabled) 1f else DisabledAlpha)) { + sides.forEach { (title, value, side) -> + MarginCropSideRow(title, value, { controller.setMarginCropSide(side.storedValue, it) }) + } + } + } + + // ── Options ─────────────────────────────────────────────── + if (isLandscape) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.weight(1f)) { + SwitchRow(readerMarginCropSymmetric(language), uiState.marginCropSymmetric, controller::setMarginCropSymmetric) + } + Box(Modifier.weight(1f)) { + SwitchRow(readerMarginCropShowWarning(language), uiState.marginCropShowWarning, controller::setMarginCropShowWarning) + } + } + } else { + SwitchRow(readerMarginCropSymmetric(language), uiState.marginCropSymmetric, controller::setMarginCropSymmetric) + SwitchRow(readerMarginCropShowWarning(language), uiState.marginCropShowWarning, controller::setMarginCropShowWarning) + } + + AnimatedVisibility(visible = uiState.marginCropEnabled && uiState.marginCropShowWarning) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.VisibilityOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(16.dp) + ) + Text( + text = readerMarginCropWarningText(language), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.height(10.dp)) + + // ── Footer ──────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { + controller.setMarginCropEnabled(false) + controller.applyMarginCropSides(0f, 0f, 0f, 0f) + }) { + Text(readerMarginCropReset(language)) + } + Spacer(Modifier.weight(1f)) + Button(onClick = onDismiss) { + Text(readerMarginCropDone(language)) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PresetChip( + selected: Boolean, + label: String, + onClick: () -> Unit +) { + FilterChip( + selected = selected, + onClick = onClick, + shape = RoundedCornerShape(999.dp), + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onSurface, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + label = { Text(label, style = MaterialTheme.typography.labelMedium) } + ) +} + +@Composable +private fun SwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedTrackColor = MaterialTheme.colorScheme.primary + ) + ) + } +} + +@Composable +private fun MarginCropSideRow( + title: String, + value: Float, + onValueChange: (Float) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 40.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(76.dp) + ) + Text( + text = "${(value * 100).toInt()}%", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.width(44.dp) + ) + Slider( + value = value.coerceIn(0f, ReaderMarginCrop.MAX_SIDE_FRACTION), + onValueChange = onValueChange, + valueRange = 0f..ReaderMarginCrop.MAX_SIDE_FRACTION, + steps = SideSteps, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) + } +} + +private fun sidesEqualPreset(uiState: ReaderUiState, value: Float): Boolean = + uiState.marginCropLeft == value && + uiState.marginCropTop == value && + uiState.marginCropRight == value && + uiState.marginCropBottom == value + +/** + * Android Bitmap → luminance grid adapter for [MarginCropAutoDetector]. + * Downsampling keeps the scan in the sub-millisecond range. + */ +private fun scanBitmapMargins(source: Bitmap): ReaderMarginCropSides? { + if (source.isRecycled || source.width < 8 || source.height < 8) return null + val gridWidth = SCAN_GRID_WIDTH + val gridHeight = (gridWidth.toLong() * source.height / source.width) + .toInt() + .coerceIn(MIN_GRID_HEIGHT, MAX_GRID_HEIGHT) + val scaled = Bitmap.createScaledBitmap(source, gridWidth, gridHeight, true) + val pixels = IntArray(gridWidth * gridHeight) + scaled.getPixels(pixels, 0, gridWidth, 0, 0, gridWidth, gridHeight) + if (scaled !== source) scaled.recycle() + return MarginCropAutoDetector.detect(gridWidth, gridHeight) { x, y -> + val pixel = pixels[y * gridWidth + x] + ((pixel shr 16 and 0xFF) * 299 + (pixel shr 8 and 0xFF) * 587 + (pixel and 0xFF) * 114) / 1000 + } +} + +/** 0..0.22 in 1% steps → 22 intervals between endpoints. */ +private const val SideSteps = 21 +private const val DisabledAlpha = 0.45f +private const val SCAN_GRID_WIDTH = 96 +private const val MIN_GRID_HEIGHT = 24 +private const val MAX_GRID_HEIGHT = 220 diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicy.kt new file mode 100644 index 000000000..c4de6e78f --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicy.kt @@ -0,0 +1,14 @@ +package io.leostrange.mrcomic.feature.reader.ui + +internal data class ReaderMarginCropLayout( + val sideColumns: Int, + val widthFraction: Float, + val verticalPaddingDp: Float, +) + +internal fun readerMarginCropLayout(isLandscape: Boolean): ReaderMarginCropLayout = + if (isLandscape) { + ReaderMarginCropLayout(sideColumns = 2, widthFraction = 0.66f, verticalPaddingDp = 8f) + } else { + ReaderMarginCropLayout(sideColumns = 1, widthFraction = 0.86f, verticalPaddingDp = 12f) + } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropStrings.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropStrings.kt new file mode 100644 index 000000000..7854be22d --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropStrings.kt @@ -0,0 +1,118 @@ +package io.leostrange.mrcomic.feature.reader.ui + +/** + * Margin-crop dialog localization ("Обрезка пустых полей"). + * Pure functions mapping language → UI strings. + */ +internal fun readerMarginCropDialogTitle(language: String): String = when (language) { + "ru" -> "Обрезка пустых полей" + "ja" -> "余白のトリミング" + "zh" -> "裁剪空白页边" + "ko" -> "빈 여백 자르기" + else -> "Margin crop" +} + +internal fun readerMarginCropEnable(language: String): String = when (language) { + "ru" -> "Включить" + "ja" -> "有効にする" + "zh" -> "启用" + "ko" -> "사용" + else -> "Enable" +} + +internal fun readerMarginCropPresetAuto(language: String): String = when (language) { + "ru" -> "Авто" + "ja" -> "自動" + "zh" -> "自动" + "ko" -> "자동" + else -> "Auto" +} + +internal fun readerMarginCropSideTop(language: String): String = when (language) { + "ru" -> "Сверху" + "ja" -> "上" + "zh" -> "上边" + "ko" -> "위" + else -> "Top" +} + +internal fun readerMarginCropSideBottom(language: String): String = when (language) { + "ru" -> "Снизу" + "ja" -> "下" + "zh" -> "下边" + "ko" -> "아래" + else -> "Bottom" +} + +internal fun readerMarginCropSideLeft(language: String): String = when (language) { + "ru" -> "Слева" + "ja" -> "左" + "zh" -> "左边" + "ko" -> "왼쪽" + else -> "Left" +} + +internal fun readerMarginCropSideRight(language: String): String = when (language) { + "ru" -> "Справа" + "ja" -> "右" + "zh" -> "右边" + "ko" -> "오른쪽" + else -> "Right" +} + +internal fun readerMarginCropSymmetric(language: String): String = when (language) { + "ru" -> "Симметричная обрезка" + "ja" -> "対称トリミング" + "zh" -> "对称裁剪" + "ko" -> "대칭 자르기" + else -> "Symmetric crop" +} + +internal fun readerMarginCropShowWarning(language: String): String = when (language) { + "ru" -> "Показывать предупреждение при обрезке" + "ja" -> "トリミング中は警告を表示" + "zh" -> "裁剪时显示警告" + "ko" -> "자르기 사용 시 경고 표시" + else -> "Show warning while crop is on" +} + +internal fun readerMarginCropWarningText(language: String): String = when (language) { + "ru" -> "Часть изображения по краям страниц скрыта" + "ja" -> "ページの端の一部が非表示になっています" + "zh" -> "页面边缘的部分内容已被隐藏" + "ko" -> "페이지 가장자리의 일부가 숨겨져 있습니다" + else -> "Part of the page edges is hidden" +} + +internal fun readerMarginCropReset(language: String): String = when (language) { + "ru" -> "Сбросить" + "ja" -> "リセット" + "zh" -> "重置" + "ko" -> "초기화" + else -> "Reset" +} + +internal fun readerMarginCropDone(language: String): String = when (language) { + "ru" -> "Готово" + "ja" -> "完了" + "zh" -> "完成" + "ko" -> "완료" + else -> "Done" +} + +internal fun readerMarginCropAutoRunning(language: String): String = when (language) { + "ru" -> "Анализ страницы…" + "ja" -> "ページを解析中…" + "zh" -> "正在分析页面…" + "ko" -> "페이지 분석 중…" + else -> "Analyzing page…" +} + +/** Content description for the locked crop button (comics/manga formats). */ +internal fun readerMarginCropLockedHint(language: String): String = when (language) { + "ru" -> "Обрезка полей доступна для PDF и DjVu" + "ja" -> "余白トリミングは PDF と DjVu で利用できます" + "zh" -> "页边裁剪仅适用于 PDF 和 DjVu" + "ko" -> "여백 자르기는 PDF 및 DjVu에서 사용할 수 있습니다" + else -> "Margin crop is available for PDF and DjVu" +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt index 9fb29b793..1bf47e5ac 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorScheme.kt @@ -21,26 +21,23 @@ internal fun readerMaterialColorScheme( ): ColorScheme { val surfaceAlpha = fallback.surface.alpha val baseScheme = if (!isTextReader) { - darkColorScheme( - primary = Color(0xFF7DB7E8), - onPrimary = Color(0xFF0F1C29), - primaryContainer = Color(0xFF243748), - onPrimaryContainer = Color(0xFFE3F1FE), - secondary = Color(0xFFD9B982), - onSecondary = Color(0xFF36250D), - secondaryContainer = Color(0xFF544122), - onSecondaryContainer = Color(0xFFF7E7CA), - background = Color(0xFF090B0E), - onBackground = Color(0xFFF2F2F2), - surface = Color(0xFF14181D), - onSurface = Color(0xFFF2F2F2), - surfaceVariant = Color(0xFF232A31), - onSurfaceVariant = Color(0xFFC5CBD2), - outline = Color(0xFF5B6772), - outlineVariant = Color(0xFF313A44), - error = fallback.error, - onError = fallback.onError - ) + // Raster reader: preset-based tinting so DAY/SEPIA/NIGHT/OLED change the page + // gutter AND the chrome controls. BUG-UI-05: light presets previously wrapped + // their colors into a darkColorScheme, so switches, sliders, progress bars and + // chrome containers kept dark companions and did not follow the Day/Sepia + // presets in light themes. Light presets now resolve to complete + // lightColorSchemes; dark presets stay dark. Background/surface roles keep the + // exact values pinned by ReaderMaterialColorSchemeTest. + val presetScheme = when { + readerPreset == ReadingPreset.OLED_BLACK -> rasterOledDarkScheme() + readerPreset == ReadingPreset.SEPIA_BOOK || textColorScheme == "SEPIA" -> rasterSepiaLightScheme() + readerPreset == ReadingPreset.NIGHT_INK || textColorScheme == "NIGHT" -> rasterNightDarkScheme() + readerPreset == ReadingPreset.NEWSPAPER -> rasterNewspaperLightScheme() + readerPreset == ReadingPreset.EINK -> rasterEinkLightScheme() + readerPreset == ReadingPreset.PAPER || textColorScheme == "DAY" -> rasterDayLightScheme() + else -> rasterFallbackDarkScheme() + } + presetScheme.copy(error = fallback.error, onError = fallback.onError) } else { when { readerPreset == ReadingPreset.OLED_BLACK -> darkColorScheme( @@ -227,3 +224,142 @@ private fun ensureContrast( val whiteRatio = (1.0f + 0.05f) / (bgLum + 0.05f) return if (blackRatio >= whiteRatio) Color.Black else Color.White } + +// ── Raster-reader preset schemes ───────────────────────────────────────────── +// Background/surface roles keep the exact values pinned by +// ReaderMaterialColorSchemeTest; companion roles (primary/secondary/containers/ +// outline*) mirror the matching text-reader palettes so switches, sliders, +// progress bars and chrome containers track the preset in light themes too. + +private fun rasterOledDarkScheme() = darkColorScheme( + primary = Color(0xFF7DB7E8), + onPrimary = Color(0xFF0F1C29), + primaryContainer = Color(0xFF243748), + onPrimaryContainer = Color(0xFFE3F1FE), + secondary = Color(0xFFD9B982), + onSecondary = Color(0xFF36250D), + secondaryContainer = Color(0xFF544122), + onSecondaryContainer = Color(0xFFF7E7CA), + background = Color(0xFF000000), + onBackground = Color(0xFFF2F2F2), + surface = Color(0xFF050505), + onSurface = Color(0xFFF2F2F2), + surfaceVariant = Color(0xFF121212), + onSurfaceVariant = Color(0xFFC5CBD2), + outline = Color(0xFF5B6772), + outlineVariant = Color(0xFF313A44) +) + +private fun rasterNightDarkScheme() = darkColorScheme( + primary = Color(0xFF7DB7E8), + onPrimary = Color(0xFF0F1C29), + primaryContainer = Color(0xFF253748), + onPrimaryContainer = Color(0xFFE2F0FD), + secondary = Color(0xFFD4B384), + onSecondary = Color(0xFF3F2A11), + secondaryContainer = Color(0xFF594225), + onSecondaryContainer = Color(0xFFF3E2C6), + background = Color(0xFF16181C), + onBackground = Color(0xFFE8E2D8), + surface = Color(0xFF1F2328), + onSurface = Color(0xFFE8E2D8), + surfaceVariant = Color(0xFF2A2F36), + onSurfaceVariant = Color(0xFFC5C0B6), + outline = Color(0xFF716A60), + outlineVariant = Color(0xFF3B403E) +) + +private fun rasterFallbackDarkScheme() = darkColorScheme( + primary = Color(0xFF7DB7E8), + onPrimary = Color(0xFF0F1C29), + primaryContainer = Color(0xFF243748), + onPrimaryContainer = Color(0xFFE3F1FE), + secondary = Color(0xFFD9B982), + onSecondary = Color(0xFF36250D), + secondaryContainer = Color(0xFF544122), + onSecondaryContainer = Color(0xFFF7E7CA), + background = Color(0xFF090B0E), + onBackground = Color(0xFFF2F2F2), + surface = Color(0xFF14181D), + onSurface = Color(0xFFF2F2F2), + surfaceVariant = Color(0xFF232A31), + onSurfaceVariant = Color(0xFFC5CBD2), + outline = Color(0xFF5B6772), + outlineVariant = Color(0xFF313A44) +) + +private fun rasterSepiaLightScheme() = lightColorScheme( + primary = Color(0xFF835D2F), + onPrimary = Color(0xFFFFF7EA), + primaryContainer = Color(0xFFF0DEC2), + onPrimaryContainer = Color(0xFF43280A), + secondary = Color(0xFF966B3A), + onSecondary = Color(0xFFFFF7EA), + secondaryContainer = Color(0xFFF5E3C7), + onSecondaryContainer = Color(0xFF45270C), + background = Color(0xFFEADFC2), + onBackground = Color(0xFF372719), + surface = Color(0xFFF4ECD8), + onSurface = Color(0xFF372719), + surfaceVariant = Color(0xFFE3D4B4), + onSurfaceVariant = Color(0xFF6A543B), + outline = Color(0xFF94785A), + outlineVariant = Color(0xFFC7B08C) +) + +private fun rasterDayLightScheme() = lightColorScheme( + primary = Color(0xFF345C7C), + onPrimary = Color(0xFFF9F4EA), + primaryContainer = Color(0xFFDCE6ED), + onPrimaryContainer = Color(0xFF142D3D), + secondary = Color(0xFF8B6841), + onSecondary = Color(0xFFF9F1E7), + secondaryContainer = Color(0xFFE8D8BF), + onSecondaryContainer = Color(0xFF382411), + background = Color(0xFFF6F1E7), + onBackground = Color(0xFF2B2118), + surface = Color(0xFFEEE6D7), + onSurface = Color(0xFF2B2118), + surfaceVariant = Color(0xFFE2D6C3), + onSurfaceVariant = Color(0xFF675745), + outline = Color(0xFF8F7D67), + outlineVariant = Color(0xFFCDBEAA) +) + +private fun rasterNewspaperLightScheme() = lightColorScheme( + primary = Color(0xFF31404F), + onPrimary = Color(0xFFF7F7F5), + primaryContainer = Color(0xFFDCE1E6), + onPrimaryContainer = Color(0xFF19232D), + secondary = Color(0xFF5E6975), + onSecondary = Color(0xFFF7F7F5), + secondaryContainer = Color(0xFFE2E6EA), + onSecondaryContainer = Color(0xFF242C34), + background = Color(0xFFF1EEE7), + onBackground = Color(0xFF202020), + surface = Color(0xFFE9E5DD), + onSurface = Color(0xFF202020), + surfaceVariant = Color(0xFFDED8D0), + onSurfaceVariant = Color(0xFF55504A), + outline = Color(0xFF80776E), + outlineVariant = Color(0xFFC3BBB1) +) + +private fun rasterEinkLightScheme() = lightColorScheme( + primary = Color(0xFF1A1A1A), + onPrimary = Color(0xFFF3F3F1), + primaryContainer = Color(0xFFD7D7D3), + onPrimaryContainer = Color(0xFF111111), + secondary = Color(0xFF4C4C4C), + onSecondary = Color(0xFFF5F5F3), + secondaryContainer = Color(0xFFE0E0DC), + onSecondaryContainer = Color(0xFF1E1E1E), + background = Color(0xFFF0EFE9), + onBackground = Color(0xFF121212), + surface = Color(0xFFE8E7E1), + onSurface = Color(0xFF121212), + surfaceVariant = Color(0xFFDDDCDA), + onSurfaceVariant = Color(0xFF50504D), + outline = Color(0xFF777773), + outlineVariant = Color(0xFFBDBCB7) +) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicy.kt new file mode 100644 index 000000000..c43ac00ae --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicy.kt @@ -0,0 +1,20 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.ReaderImageScaleMode +import io.leostrange.mrcomic.core.model.ReadingMode + +internal fun resolvePageImageScaleMode( + format: ComicFormat?, + readingMode: ReadingMode, + requestedMode: String, +): String { + val isDocument = format == ComicFormat.PDF || format == ComicFormat.DJVU + if (isDocument && readingMode == ReadingMode.DUAL_PAGE) { + return ReaderImageScaleMode.FIT_HEIGHT.storedValue + } + if (format == ComicFormat.DJVU && requestedMode == ReaderImageScaleMode.FIT_WIDTH.storedValue) { + return ReaderImageScaleMode.FIT_HEIGHT.storedValue + } + return requestedMode +} diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt index 51da75563..20e7f3ee6 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPagedLayoutJs.kt @@ -118,9 +118,11 @@ internal fun readerPagedCoreJs( viewport.style.boxSizing='border-box'; viewport.style.paddingTop='0px'; viewport.style.paddingBottom='0px'; - var rawUsableHeight=Math.max(lineHeight*3,clipHeight-pageInsetTop-pageInsetBottom-Math.max(6,Math.ceil(lineHeight*0.25))); - var usableLineCount=Math.max(3,Math.floor(rawUsableHeight/lineHeight)); - var usableHeight=Math.max(lineHeight*3,usableLineCount*lineHeight); + // The Compose reader container already owns the complete outer gutter. + // Keep the viewport remainder in the page budget. Quantizing this value + // down by lineHeight turns that remainder into a variable bottom gutter. + var rawUsableHeight=Math.max(lineHeight*3,clipHeight-pageInsetTop-pageInsetBottom); + var usableHeight=rawUsableHeight; root.style.setProperty('--mrcomic-page-visible-height',usableHeight+'px'); root.style.setProperty('--mrcomic-page-inset-top',pageInsetTop+'px'); root.style.setProperty('--mrcomic-page-inset-bottom',pageInsetBottom+'px'); @@ -327,7 +329,10 @@ internal fun readerPagedCoreJs( while(currentcurrent+lineHeight*2){ var nextStartAfterMedia=contentHeight; for(var frontIdx=0;frontIdx, val readerStylePresetSlots: List, val savedReaderStylePresetEntries: List, @@ -134,12 +135,31 @@ internal object ReaderPreferenceRestorer { val imageScaleMode = ReaderImageScaleMode.fromStored( pref(PreferencesKeys.READER_IMAGE_SCALE_MODE, ReaderImageScaleMode.FIT_WIDTH.storedValue) ) - val imageMarginCropHorizontal = pref( - PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, 0.0f - ).coerceIn(0f, 0.22f) - val imageMarginCropVertical = pref( - PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, 0.0f - ).coerceIn(0f, 0.22f) + // Per-side crop values; -1f marks "key absent" so the legacy symmetric + // H/V preferences can seed them on the first run after the upgrade. + val storedLeft = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, -1f) + val storedTop = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, -1f) + val storedRight = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, -1f) + val storedBottom = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, -1f) + val legacyHorizontal = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, 0f) + .coerceIn(0f, io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop.MAX_SIDE_FRACTION) + val legacyVertical = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, 0f) + .coerceIn(0f, io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop.MAX_SIDE_FRACTION) + val seededCrop = io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop() + .seededFromLegacy(legacyHorizontal, legacyVertical) + val marginCrop = io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop( + // A legacy user with non-zero H/V crop keeps it enabled after the upgrade. + enabled = pref( + PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, + legacyHorizontal > 0f || legacyVertical > 0f + ), + left = if (storedLeft >= 0f) storedLeft else seededCrop.left, + top = if (storedTop >= 0f) storedTop else seededCrop.top, + right = if (storedRight >= 0f) storedRight else seededCrop.right, + bottom = if (storedBottom >= 0f) storedBottom else seededCrop.bottom, + symmetric = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_SYMMETRIC, true), + showWarning = pref(PreferencesKeys.READER_PAGE_MARGIN_CROP_SHOW_WARNING, true) + ).withSymmetricEnforced() val preload = pref(PreferencesKeys.READER_PRELOAD_PAGES, renderProfile.defaultPreloadPages) .coerceIn(2, 8) .coerceAtMost(renderProfile.maxPreloadPages) @@ -147,6 +167,7 @@ internal object ReaderPreferenceRestorer { // Text reader settings val fontSize = pref(PreferencesKeys.TEXT_FONT_SIZE, 18).coerceIn(12, 32) val colorScheme = pref(PreferencesKeys.TEXT_COLOR_SCHEME, "DAY") + val graphicColorScheme = pref(PreferencesKeys.GRAPHIC_COLOR_SCHEME, DEFAULT_GRAPHIC_COLOR_SCHEME) val customTextColor = pref(PreferencesKeys.TEXT_CUSTOM_TEXT_COLOR, Long.MIN_VALUE) .takeUnless { it == Long.MIN_VALUE } val customBackgroundColor = pref(PreferencesKeys.TEXT_CUSTOM_BACKGROUND_COLOR, Long.MIN_VALUE) @@ -213,6 +234,7 @@ internal object ReaderPreferenceRestorer { val chromeShowTranslateIcon = pref(PreferencesKeys.READER_CHROME_SHOW_TRANSLATE, true) val chromeShowBrightnessIcon = pref(PreferencesKeys.READER_CHROME_SHOW_BRIGHTNESS, true) val chromeShowAutoScrollIcon = pref(PreferencesKeys.READER_CHROME_SHOW_AUTO_SCROLL, true) + val chromeShowCropIcon = pref(PreferencesKeys.READER_CHROME_SHOW_CROP, true) val legacyReaderStylePresetSlots = listOf( ReaderStylePresetSlot(1, pref(PreferencesKeys.READER_STYLE_PRESET_1, "").ifBlank { null }), @@ -249,11 +271,11 @@ internal object ReaderPreferenceRestorer { bottomToolbarOpacity = bottomToolbarOpacity, toolbarBlur = toolbarBlur, imageScaleMode = imageScaleMode, - imageMarginCropHorizontal = imageMarginCropHorizontal, - imageMarginCropVertical = imageMarginCropVertical, + marginCrop = marginCrop, preload = preload, fontSize = fontSize, colorScheme = colorScheme, + graphicColorScheme = graphicColorScheme, customTextColor = customTextColor, customBackgroundColor = customBackgroundColor, customAccentColor = customAccentColor, @@ -297,6 +319,7 @@ internal object ReaderPreferenceRestorer { chromeShowTranslateIcon = chromeShowTranslateIcon, chromeShowBrightnessIcon = chromeShowBrightnessIcon, chromeShowAutoScrollIcon = chromeShowAutoScrollIcon, + chromeShowCropIcon = chromeShowCropIcon, readerStylePresetEntries = readerStylePresetEntries, readerStylePresetSlots = readerStylePresetSlots, savedReaderStylePresetEntries = savedReaderStylePresetEntries, @@ -341,11 +364,17 @@ internal object ReaderPreferenceRestorer { bottomToolbarOpacity = p.bottomToolbarOpacity, toolbarBlur = p.toolbarBlur, imageScaleMode = p.imageScaleMode.storedValue, - imageMarginCropHorizontal = p.imageMarginCropHorizontal, - imageMarginCropVertical = p.imageMarginCropVertical, + marginCropEnabled = p.marginCrop.enabled, + marginCropLeft = p.marginCrop.left, + marginCropTop = p.marginCrop.top, + marginCropRight = p.marginCrop.right, + marginCropBottom = p.marginCrop.bottom, + marginCropSymmetric = p.marginCrop.symmetric, + marginCropShowWarning = p.marginCrop.showWarning, preloadPages = p.preload, textFontSize = p.fontSize, textColorScheme = p.colorScheme, + graphicColorScheme = p.graphicColorScheme, textCustomTextColor = p.customTextColor, textCustomBackgroundColor = p.customBackgroundColor, textCustomAccentColor = p.customAccentColor, @@ -391,7 +420,8 @@ internal object ReaderPreferenceRestorer { chromeShowDirectionIcon = p.chromeShowDirectionIcon, chromeShowTranslateIcon = p.chromeShowTranslateIcon, chromeShowBrightnessIcon = p.chromeShowBrightnessIcon, - chromeShowAutoScrollIcon = p.chromeShowAutoScrollIcon + chromeShowAutoScrollIcon = p.chromeShowAutoScrollIcon, + chromeShowCropIcon = p.chromeShowCropIcon ) } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt index b20955e6f..a1a5eb47f 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderProgressController.kt @@ -77,6 +77,36 @@ internal class ReaderProgressController( */ internal var totalBookSections: Int = 0 + /** BUG-READER-03: periodic position save job — runs every 5 s while the reader is active. */ + private var periodicSaveJob: Job? = null + + /** + * BUG-READER-03: Start periodic position snapshots so a process-kill between page turns + * cannot lose more than ~5 seconds of reading progress. Only one periodic job runs at a time. + */ + fun startPeriodicPositionSave() { + periodicSaveJob?.cancel() + periodicSaveJob = viewModelScope.launch { + while (true) { + delay(PERIODIC_SAVE_INTERVAL_MS) + val comic = _uiState.value.comic ?: continue + val positionJson = buildPositionJson( + _uiState.value, comic.format, _uiState.value.currentPage + ) ?: continue + // Only write when the position actually changed since the last persist. + if (!isSamePersistedPosition(lastPersistedPositionJson, positionJson)) { + enqueuePositionOnlySave(comic, _uiState.value.currentPage, positionJson) + } + } + } + } + + /** Stop the periodic position save (called when the reader is closed). */ + fun stopPeriodicPositionSave() { + periodicSaveJob?.cancel() + periodicSaveJob = null + } + /** Returns a stable, section-ordered snapshot for EPUB progress accumulation. */ internal fun snapshotSectionPageCounts(): Map = sectionPageCounts.snapshot() @@ -148,6 +178,70 @@ internal class ReaderProgressController( enqueuePositionOnlySave(comic, _uiState.value.currentPage, positionJson) } + /** + * Force-persists the current position on reader close, bypassing the dedup guard. + * + * BUG-READER-03: [savePositionSnapshot] skips the write when the position JSON matches + * [lastPersistedPositionJson]. During a rapid exit the WebView may not have reported its + * latest scroll back to [_uiState], so the snapshot looks identical — but the database + * might actually be empty or stale (e.g. a previous flush was lost to a process kill). + * This method always enqueues a position-only write so the close path is lossless. + */ + private fun forceSavePositionOnClose() { + val comic = _uiState.value.comic ?: return + val positionJson = buildPositionJson(_uiState.value, comic.format, _uiState.value.currentPage) + ?: return + val normalizedPage = _uiState.value.currentPage.coerceAtLeast(0) + val pending = PendingProgressSave( + comicId = comic.id, + page = normalizedPage, + totalPages = _uiState.value.totalPages.coerceAtLeast(1), + countsTowardReadingProgress = false, + characterOffset = if (_uiState.value.readingMode == ReadingMode.WEBTOON) { + _uiState.value.freeScrollCharacterOffset.takeIf { it >= 0 } + } else { + _uiState.value.sectionCharacterOffset.takeIf { it > 0 } + }, + positionJson = positionJson, + positionOnly = true + ) + // Only skip if there is already an identical *pending* save queued (avoids duplicate work + // within the same close sequence). We intentionally do NOT check lastPersistedPositionJson + // because the close path must always write to the database even if it appears redundant. + if (pending == pendingProgressSave) return + pendingProgressSave = pending + } + + /** + * BUG-READER-02: Immediate position save used when the user explicitly changes reading mode. + * Unlike [savePositionSnapshot] (debounced 220 ms) this flushes the pending write synchronously + * so a rapid close after a mode switch cannot lose the new mode in the structured position. + */ + fun savePositionImmediate() { + val comic = _uiState.value.comic ?: return + val positionJson = buildPositionJson(_uiState.value, comic.format, _uiState.value.currentPage) + ?: return + val normalizedPage = _uiState.value.currentPage.coerceAtLeast(0) + val pending = PendingProgressSave( + comicId = comic.id, + page = normalizedPage, + totalPages = _uiState.value.totalPages.coerceAtLeast(1), + countsTowardReadingProgress = false, + characterOffset = if (_uiState.value.readingMode == ReadingMode.WEBTOON) { + _uiState.value.freeScrollCharacterOffset.takeIf { it >= 0 } + } else { + _uiState.value.sectionCharacterOffset.takeIf { it > 0 } + }, + positionJson = positionJson, + positionOnly = true + ) + pendingProgressSave = pending + progressSaveJob?.cancel() + progressSaveJob = viewModelScope.launch { + flushPendingProgressSave() + } + } + private fun enqueuePositionOnlySave( comic: io.leostrange.mrcomic.core.model.Comic, page: Int, @@ -186,8 +280,7 @@ internal class ReaderProgressController( val previousPersistedPage = lastPersistedProgress ?.takeIf { it.comicId == pending.comicId } ?.page - val storedPageCount = libraryRepository.getComicById(pending.comicId)?.pageCount ?: 0 - val safeTotalPages = maxOf(pending.totalPages, storedPageCount).coerceAtLeast(1) + val safeTotalPages = pending.totalPages.coerceAtLeast(1) if (!pending.positionOnly) { libraryRepository.updateProgress( comicId = pending.comicId, @@ -235,8 +328,7 @@ internal class ReaderProgressController( lastPersistedProgress = persistedPageMarkerAfterFlush(lastPersistedProgress, pending) lastPersistedPositionJson = pending.positionJson val currentComic = _uiState.value.comic ?: return - val authoritativeTotal = maxOf(pending.totalPages, storedPageCount) - val reachedLastPageSafe = authoritativeTotal > 0 && pending.page >= authoritativeTotal - 1 + val reachedLastPageSafe = pending.totalPages > 0 && pending.page >= pending.totalPages - 1 val isHeavy = currentComic.format.isHeavyReflowableFormat() || currentComic.format.isTextReadingFormat() val titleCompletionPolicy = resolveTitleCompletionPolicy( reachedLastPage = reachedLastPageSafe, @@ -246,7 +338,7 @@ internal class ReaderProgressController( sessionManualPageTurns = readerSessionCoordinator.currentManualPageTurns, goalProgressDelta = goalProgressDelta, isHeavyReflowable = isHeavy, - totalPages = authoritativeTotal + totalPages = pending.totalPages ) if (titleCompletionPolicy.shouldComplete) { libraryRepository.markCompleted(pending.comicId, completed = true) @@ -427,16 +519,37 @@ internal class ReaderProgressController( fun accumulatedTotalPagesForEpub(): Int { return EpubProgressCalculator.estimatedTotalPages( sectionPageCounts = sectionPageCounts.snapshot(), - totalSections = totalBookSections + totalSections = totalBookSections, + stableEstimateOverride = sectionPageCounts.stableEstimate() ) } // ── Session lifecycle ────────────────────────────────────────────────── - fun emitReaderClosed(appScope: io.leostrange.mrcomic.core.domain.coroutines.AppCoroutineScope) { - // Close can happen between scroll callbacks; enqueue one final semantic snapshot before - // the ViewModel cancels its local debounce job. - savePositionSnapshot() + /** + * Prepare the reader-close snapshot and return the analytics payload. + * + * BUG-READER-03: The actual database flush is **not** done here — it is the caller's + * responsibility to invoke [flushPendingProgressSave] synchronously *before* tearing down + * reader resources. This two-phase design (prepare → flush) replaces the former + * fire-and-forget `appScope.launch { flush }` that could lose the position on a fast + * process kill after `onCleared`. + */ + fun emitReaderClosed(): ReaderClosedAnalytics? { + // Close can happen between scroll callbacks; enqueue one final semantic snapshot and + // flush immediately so the 220ms debounce cannot lose the position on rapid exit. + // + // BUG-READER-03: use forceSavePositionOnClose instead of savePositionSnapshot. + // The normal snapshot is a no-op when the position JSON matches lastPersistedPositionJson, + // but during a rapid exit the WebView's scroll position may not have been reported back + // to _uiState yet (the 120ms free-scroll debounce hasn't fired). By force-enqueuing + // a position-only save and bypassing the dedup check, we guarantee the reader's last + // known position is always persisted on close — even if it appears identical to the + // previously stored value. The extra write is a single UPDATE on a single row and + // only happens once per reader session close. + forceSavePositionOnClose() + progressSaveJob?.cancel() + // NOTE: flush is NOT done here — caller must flush synchronously. val state = _uiState.value val currentComic = state.comic val closedSession = readerSessionCoordinator.close( @@ -444,16 +557,29 @@ internal class ReaderProgressController( currentComicCompleted = currentComic?.isCompleted == true, currentPage = state.currentPage ) - ?: return + ?: return null val session = closedSession.session val sessionMetrics = closedSession.metrics val finishedAtMillis = System.currentTimeMillis() - if (shouldRecordReaderSessionMinutes(sessionMetrics)) { + return ReaderClosedAnalytics( + session = session, + sessionMetrics = sessionMetrics, + readingModeName = state.readingMode.name, + finishedAtMillis = finishedAtMillis + ) + } + + /** + * BUG-READER-03: Record session minutes and track the reader-closed analytics event. + * Called from [appScope] after the synchronous flush in [ReaderViewModel.onCleared]. + */ + fun trackReaderClosed(analytics: ReaderClosedAnalytics, appScope: io.leostrange.mrcomic.core.domain.coroutines.AppCoroutineScope) { + if (shouldRecordReaderSessionMinutes(analytics.sessionMetrics)) { appScope.launch { runCatching { dailyReadingGoalStore.recordSessionMinutes( - durationMillis = finishedAtMillis - session.startedAtMillis, - nowMillis = finishedAtMillis + durationMillis = analytics.finishedAtMillis - analytics.session.startedAtMillis, + nowMillis = analytics.finishedAtMillis ) }.onFailure { error -> Log.e("ReaderProgressController", "Failed to record reading session minutes", error) @@ -462,17 +588,25 @@ internal class ReaderProgressController( } analyticsTracker.track( buildReaderClosedAnalyticsEvent( - comicId = session.comicId, - format = session.format, - totalPages = session.totalPages, - readingMode = state.readingMode.name, - startedAtMillis = session.startedAtMillis, - finishedAtMillis = finishedAtMillis, - sessionMetrics = sessionMetrics + comicId = analytics.session.comicId, + format = analytics.session.format, + totalPages = analytics.session.totalPages, + readingMode = analytics.readingModeName, + startedAtMillis = analytics.session.startedAtMillis, + finishedAtMillis = analytics.finishedAtMillis, + sessionMetrics = analytics.sessionMetrics ) ) } + /** Data needed to fire the reader-closed analytics event outside the synchronous flush path. */ + internal data class ReaderClosedAnalytics( + val session: io.leostrange.mrcomic.feature.reader.domain.session.ReaderSessionSnapshot, + val sessionMetrics: io.leostrange.mrcomic.feature.reader.domain.session.ReaderClosedSessionMetrics, + val readingModeName: String, + val finishedAtMillis: Long, + ) + // ── Structured position (TEXT-01) ────────────────────────────────────── /** @@ -512,4 +646,9 @@ internal class ReaderProgressController( } // ── Internal helpers ─────────────────────────────────────────────────── + + private companion object { + /** Interval between periodic position snapshots (BUG-READER-03). */ + const val PERIODIC_SAVE_INTERVAL_MS = 5_000L + } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt index ed11a8741..e1c436e83 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingModeController.kt @@ -32,7 +32,9 @@ internal class ReaderReadingModeController( private val markReaderPresetCustom: () -> Unit, private val getLastTextWebtoonCursor: () -> ReaderTextWebtoonCursor? = { null }, private val seedTextWebtoonCursor: (ReaderTextWebtoonCursor?) -> Unit = {}, - private val onAutoScrollModeChanged: (ReadingMode) -> Unit = {} + private val onAutoScrollModeChanged: (ReadingMode) -> Unit = {}, + /** BUG-READER-02: immediate position save on mode change to survive rapid close. */ + private val savePositionImmediate: () -> Unit = {} ) { var portraitReadingMode: ReadingMode = ReadingMode.PAGE_LTR var portraitPagedReadingMode: ReadingMode = ReadingMode.PAGE_LTR @@ -49,11 +51,13 @@ internal class ReaderReadingModeController( return } rememberPortraitMode(mode) - markReaderPresetCustom() applyReadingMode(mode) viewModelScope.launch { readerPreferences.set(PreferencesKeys.READING_MODE, mode.name) } + // BUG-READER-02: Flush the structured position immediately so the new mode + // survives a rapid close (the normal 220 ms debounce could lose it). + savePositionImmediate() } fun onOrientationChanged( @@ -118,7 +122,7 @@ internal class ReaderReadingModeController( pagedSubpageIndex = currentState.sectionCurrentPage, pagedSubpageCount = currentState.sectionPageCount, totalWebtoonSections = sectionCount, - characterOffset = currentState.sectionCharacterOffset.takeIf { it > 0 }, + characterOffset = currentState.sectionCharacterOffset.takeIf { it >= 0 }, fragment = currentState.pendingScrollToAnchor ) else -> null @@ -172,8 +176,12 @@ internal class ReaderReadingModeController( val textPagePosition = resolvedPosition as? ReaderContainerPosition.TextPage val textWebtoonPosition = resolvedPosition as? ReaderContainerPosition.TextWebtoon val nextIsTextWebtoon = nextContainerKind == ReaderContainerKind.TEXT_WEBTOON + // BUG-READER-05: Explicitly preserve readerPreset and textColorScheme + // to ensure mode changes don't reset the theme. state.copy( readingMode = mode, + readerPreset = state.readerPreset, + textColorScheme = state.textColorScheme, currentPage = alignedPage, sectionCurrentPage = when { textPagePosition != null -> textPagePosition.pageInSplit diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt index 6f9a8e654..ec6846dda 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTab.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.theme.style +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop @OptIn(ExperimentalLayoutApi::class) @Composable @@ -223,45 +224,47 @@ internal fun ReaderReadingTab( ) ) } - item { - ReaderSliderRow( - title = readerHeaderFooterFontSizeTitle(strings.languageCode), - valueText = "${uiState.headerFooterFontSize}sp", - value = uiState.headerFooterFontSize.toFloat(), - valueRange = 10f..20f, - steps = 9, - onValueChange = { onHeaderFooterFontSizeChange(it.toInt()) } - ) - } - item { - ReaderSliderRow( - title = readerHeaderFooterVerticalPaddingTitle(strings.languageCode), - valueText = "${uiState.headerFooterVerticalPadding}dp", - value = uiState.headerFooterVerticalPadding.toFloat(), - valueRange = 4f..20f, - steps = 15, - onValueChange = { onHeaderFooterVerticalPaddingChange(it.toInt()) } - ) - } - item { - ReaderSliderRow( - title = readerHeaderFooterLeftInsetTitle(strings.languageCode), - valueText = "${uiState.headerFooterLeftPadding}dp", - value = uiState.headerFooterLeftPadding.toFloat(), - valueRange = 8f..32f, - steps = 23, - onValueChange = { onHeaderFooterLeftPaddingChange(it.toInt()) } - ) - } - item { - ReaderSliderRow( - title = readerHeaderFooterRightInsetTitle(strings.languageCode), - valueText = "${uiState.headerFooterRightPadding}dp", - value = uiState.headerFooterRightPadding.toFloat(), - valueRange = 8f..32f, - steps = 23, - onValueChange = { onHeaderFooterRightPaddingChange(it.toInt()) } - ) + if (isTextReader) { + item { + ReaderSliderRow( + title = readerHeaderFooterFontSizeTitle(strings.languageCode), + valueText = "${uiState.headerFooterFontSize}sp", + value = uiState.headerFooterFontSize.toFloat(), + valueRange = 10f..20f, + steps = 9, + onValueChange = { onHeaderFooterFontSizeChange(it.toInt()) } + ) + } + item { + ReaderSliderRow( + title = readerHeaderFooterVerticalPaddingTitle(strings.languageCode), + valueText = "${uiState.headerFooterVerticalPadding}dp", + value = uiState.headerFooterVerticalPadding.toFloat(), + valueRange = 4f..20f, + steps = 15, + onValueChange = { onHeaderFooterVerticalPaddingChange(it.toInt()) } + ) + } + item { + ReaderSliderRow( + title = readerHeaderFooterLeftInsetTitle(strings.languageCode), + valueText = "${uiState.headerFooterLeftPadding}dp", + value = uiState.headerFooterLeftPadding.toFloat(), + valueRange = 8f..32f, + steps = 23, + onValueChange = { onHeaderFooterLeftPaddingChange(it.toInt()) } + ) + } + item { + ReaderSliderRow( + title = readerHeaderFooterRightInsetTitle(strings.languageCode), + valueText = "${uiState.headerFooterRightPadding}dp", + value = uiState.headerFooterRightPadding.toFloat(), + valueRange = 8f..32f, + steps = 23, + onValueChange = { onHeaderFooterRightPaddingChange(it.toInt()) } + ) + } } item { ReaderSwitchRow( @@ -392,6 +395,7 @@ internal fun ReaderHeaderFooterPreview( eink = false ) } + MrComicPreviewBackdrop(shape = MaterialTheme.shapes.large) { Surface( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, @@ -425,6 +429,7 @@ internal fun ReaderHeaderFooterPreview( } } } + } } } @@ -529,4 +534,4 @@ internal fun ReaderHeaderFooterSlotStrip( } } -// Localization strings extracted to ReaderControlCenterStrings.kt \ No newline at end of file +// Localization strings extracted to ReaderControlCenterStrings.kt diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt index 2dd26bbc5..810055714 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSaveQuoteController.kt @@ -2,6 +2,7 @@ package io.leostrange.mrcomic.feature.reader.ui import android.util.Log import io.leostrange.mrcomic.core.data.repository.QuoteRepository +import io.leostrange.mrcomic.core.model.isTextReadingFormat import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsEvent import io.leostrange.mrcomic.core.domain.analytics.ReadingAnalyticsTracker import kotlinx.coroutines.CoroutineScope @@ -61,6 +62,15 @@ internal class ReaderSaveQuoteController( ) { val comic = _uiState.value.comic ?: return val page = _uiState.value.currentPage + val state = _uiState.value + // BUG-CANDIDATE-01: Build structured position for precise quote navigation + val positionJson = buildQuotePositionJson(state, comic.format, page) + val characterOffset = if (state.readingMode == io.leostrange.mrcomic.core.model.ReadingMode.WEBTOON) { + state.freeScrollCharacterOffset.takeIf { it >= 0 } + } else { + state.sectionCharacterOffset.takeIf { it > 0 } + } + val domAnchor = state.pendingScrollToAnchor viewModelScope.launch { runCatching { quoteRepository.saveQuote( @@ -69,7 +79,10 @@ internal class ReaderSaveQuoteController( text = text, translatedText = translatedText, sourceLanguage = sourceLanguage, - targetLanguage = targetLanguage + targetLanguage = targetLanguage, + positionJson = positionJson, + characterOffset = characterOffset, + domAnchor = domAnchor ) }.onSuccess { result -> val readerText = localizedReaderText() @@ -93,4 +106,39 @@ internal class ReaderSaveQuoteController( } } } + + /** + * BUG-CANDIDATE-01: Build structured position JSON for a quote. + * Mirrors [ReaderProgressController.buildPositionJson] so the quote carries + * enough information to navigate back to the exact reading position. + */ + private fun buildQuotePositionJson( + state: ReaderUiState, + format: io.leostrange.mrcomic.core.model.ComicFormat, + page: Int + ): String? { + val mode = state.readingMode + val isText = format.isTextReadingFormat() + val webtoonFraction = if (mode == io.leostrange.mrcomic.core.model.ReadingMode.WEBTOON) { + state.freeScrollProgression.takeIf { it in 0.0..1.0 }?.toFloat() + } else { + null + } + val position = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPosition( + engineSectionIndex = if (isText) state.currentPage.coerceAtLeast(0) else page.coerceAtLeast(0), + visualPageIndex = if (isText) state.sectionCurrentPage.coerceAtLeast(0) else page.coerceAtLeast(0), + characterOffset = if (mode == io.leostrange.mrcomic.core.model.ReadingMode.WEBTOON) { + state.freeScrollCharacterOffset.takeIf { it >= 0 } + ?: state.sectionCharacterOffset.takeIf { it > 0 } + } else { + state.sectionCharacterOffset.takeIf { it > 0 } + }, + domAnchor = state.pendingScrollToAnchor, + mode = mode, + webtoonScrollFraction = webtoonFraction, + updatedAtMillis = System.currentTimeMillis(), + schemaVersion = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPosition.SCHEMA_VERSION + ) + return io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPositionCodec.encode(position) + } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt index a0938f86b..90c1df127 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderScreen.kt @@ -81,18 +81,21 @@ fun ReaderScreen( val ttsRuntimeState by ttsController.state.collectAsState() val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val isTextReader = uiState.readerContainerKind.isTextContainer() - val supportsDocumentMarginCrop = uiState.comic?.format == ComicFormat.PDF || uiState.comic?.format == ComicFormat.DJVU - val effectiveMarginCropHorizontal = if (supportsDocumentMarginCrop) uiState.imageMarginCropHorizontal else 0f - val effectiveMarginCropVertical = if (supportsDocumentMarginCrop) uiState.imageMarginCropVertical else 0f - val effectivePageImageScaleMode = - if ( - uiState.comic?.format == ComicFormat.DJVU && - uiState.imageScaleMode == ReaderImageScaleMode.FIT_WIDTH.storedValue - ) { - ReaderImageScaleMode.FIT_HEIGHT.storedValue - } else { - uiState.imageScaleMode - } + val supportsImageMarginCrop = supportsImageMarginCrop(uiState.readerContainerKind, uiState.comic?.format) + val effectiveMarginCrop = resolveEffectiveImageMarginCrop( + containerKind = uiState.readerContainerKind, + format = uiState.comic?.format, + enabled = uiState.marginCropEnabled, + left = uiState.marginCropLeft, + top = uiState.marginCropTop, + right = uiState.marginCropRight, + bottom = uiState.marginCropBottom + ) + val effectivePageImageScaleMode = resolvePageImageScaleMode( + format = uiState.comic?.format, + readingMode = uiState.readingMode, + requestedMode = uiState.imageScaleMode, + ) val supportsLandscapeSpread = !isTextReader && isLandscape && configuration.screenWidthDp >= 600 val activeReaderPreset = remember(uiState.readerPreset) { ReadingPreset.fromStored(uiState.readerPreset) @@ -207,7 +210,8 @@ fun ReaderScreen( startSlot = left, centerSlot = center, endSlot = right, comicTitle = uiState.comic?.title, chapterTitle = currentChapterTitle, clockText = clockText, currentPage = effectiveCurrentPage, - totalPages = effectiveTotalPages, readingMode = uiState.readingMode + totalPages = effectiveTotalPages, readingMode = uiState.readingMode, + canonicalProgressPercent = uiState.effectiveProgressPercent ) val headerOverlayLine = remember( uiState.headerLeftSlot, uiState.headerCenterSlot, uiState.headerRightSlot, @@ -482,6 +486,7 @@ fun ReaderScreen( // pixel-scroll cannot advance the document underneath an open dialog. val anyBottomSheetOpen = uiState.showTocSheet || uiState.showTextSettings || + uiState.showMarginCropDialog || showReaderAudioSheet || showTextTranslationPageSheet || showRsvpOverlay || @@ -545,14 +550,24 @@ fun ReaderScreen( } val textReaderModifier = Modifier .fillMaxSize() - .then(textSystemInsetsModifier) - .padding( - vertical = with(density) { textSentenceInsetPx.toDp() } - ) val textChromeLayoutInsets = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = viewportGeometry.chromeTopInsetCssPx, measuredBottomCssPx = viewportGeometry.chromeBottomInsetCssPx, + // Keep one full line of air at each edge and one equal + // line-sized safety step between the text and an + // overlaid chrome bar. This reserve is constant, so + // opening/closing chrome cannot reflow the document. + persistentGutterCssPx = ( + readerTextTwoLineGutterPx(textSentenceInsetPx) / density.density + ).roundToInt(), + persistentGutterPx = readerTextTwoLineGutterPx(textSentenceInsetPx), ) + val stableTextReaderModifier = textReaderModifier + .then(textSystemInsetsModifier) + .padding( + top = with(density) { textChromeLayoutInsets.outerTopPx.toDp() }, + bottom = with(density) { textChromeLayoutInsets.outerBottomPx.toDp() }, + ) val imageReaderModifier = Modifier .fillMaxSize() .then( @@ -575,10 +590,9 @@ fun ReaderScreen( readerText = readerText, languageCode = strings.languageCode, tapZoneLayout = tapZoneLayout, - effectiveMarginCropHorizontal = effectiveMarginCropHorizontal, - effectiveMarginCropVertical = effectiveMarginCropVertical, + effectiveMarginCrop = effectiveMarginCrop, effectivePageImageScaleMode = effectivePageImageScaleMode, - textReaderModifier = textReaderModifier, + textReaderModifier = stableTextReaderModifier, imageReaderModifier = imageReaderModifier, textChromeTopInsetCssPx = textChromeLayoutInsets.topCssPx, textChromeBottomInsetCssPx = textChromeLayoutInsets.bottomCssPx, @@ -643,11 +657,25 @@ fun ReaderScreen( onDismissFootnote = { viewModel.footnoteController.dismissFootnote() }, onExpandFootnote = { viewModel.footnoteController.expandFootnote() }, onCollapseFootnote = { viewModel.footnoteController.collapseFootnote() }, + onToggleMarginCrop = { + // Close the chrome panels and show the crop dialog over + // the page, like the classic document readers do. + viewModel.chromeController.hideChrome() + viewModel.settingsController.setMarginCropDialogVisible(true) + }, ) } } } + if (uiState.showMarginCropDialog && supportsImageMarginCrop) { + ReaderMarginCropDialog( + uiState = uiState, + viewModel = viewModel, + onDismiss = { viewModel.settingsController.setMarginCropDialogVisible(false) } + ) + } + ReaderBottomSheets( host = rememberReaderBottomSheetHost( uiState = uiState, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt index 45e8e2ef5..46a597583 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsActions.kt @@ -72,9 +72,21 @@ interface ReaderSettingsActions { // ── Image ───────────────────────────────────────────────────────────── fun setImageScaleMode(value: String) + + /** Symmetric pair writes kept for the style tab / settings sliders. */ fun setImageMarginCropHorizontal(value: Float) fun setImageMarginCropVertical(value: Float) + /** Per-side crop write used by the margin-crop dialog. */ + fun setMarginCropSide(side: String, value: Float) + + /** Applies all four sides at once (used by the auto-detect preset). */ + fun applyMarginCropSides(left: Float, top: Float, right: Float, bottom: Float) + fun setMarginCropEnabled(enabled: Boolean) + fun setMarginCropSymmetric(symmetric: Boolean) + fun setMarginCropShowWarning(show: Boolean) + fun setMarginCropDialogVisible(visible: Boolean) + // ── TTS ─────────────────────────────────────────────────────────────── fun setTtsSpeed(value: Float) fun setTtsProvider(value: String) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt index 7f798e6c1..c031a26dd 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderSettingsController.kt @@ -4,6 +4,8 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import io.leostrange.mrcomic.core.data.preferences.PreferencesKeys import io.leostrange.mrcomic.core.data.preferences.UserPreferences +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCrop +import io.leostrange.mrcomic.feature.reader.domain.crop.ReaderMarginCropSide import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.reader.ui.preset.toReaderStylePresetSnapshot import io.leostrange.mrcomic.core.model.ReaderImageScaleMode @@ -554,15 +556,131 @@ class ReaderSettingsController( } override fun setImageMarginCropHorizontal(value: Float) { - val safe = value.coerceIn(0f, 0.22f) - _uiState.update { it.copy(imageMarginCropHorizontal = safe) } - viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, safe) } + val safe = ReaderMarginCrop.coerceSide(value) + _uiState.update { + it.copy( + marginCropLeft = safe, + marginCropRight = safe, + marginCropEnabled = it.marginCropEnabled || safe > 0f + ) + } + persistMarginCrop( + enabled = _uiState.value.marginCropEnabled, + left = safe, + top = _uiState.value.marginCropTop, + right = safe, + bottom = _uiState.value.marginCropBottom + ) } override fun setImageMarginCropVertical(value: Float) { - val safe = value.coerceIn(0f, 0.22f) - _uiState.update { it.copy(imageMarginCropVertical = safe) } - viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, safe) } + val safe = ReaderMarginCrop.coerceSide(value) + _uiState.update { + it.copy( + marginCropTop = safe, + marginCropBottom = safe, + marginCropEnabled = it.marginCropEnabled || safe > 0f + ) + } + persistMarginCrop( + enabled = _uiState.value.marginCropEnabled, + left = _uiState.value.marginCropLeft, + top = safe, + right = _uiState.value.marginCropRight, + bottom = safe + ) + } + + override fun setMarginCropSide(side: String, value: Float) { + val cropSide = ReaderMarginCropSide.fromStored(side) ?: return + val next = currentMarginCrop().withSide(cropSide, value) + updateMarginCropState(next) + } + + override fun applyMarginCropSides(left: Float, top: Float, right: Float, bottom: Float) { + val next = currentMarginCrop().copy( + left = ReaderMarginCrop.coerceSide(left), + top = ReaderMarginCrop.coerceSide(top), + right = ReaderMarginCrop.coerceSide(right), + bottom = ReaderMarginCrop.coerceSide(bottom), + enabled = true + ) + updateMarginCropState(next) + } + + override fun setMarginCropEnabled(enabled: Boolean) { + updateMarginCropState(currentMarginCrop().copy(enabled = enabled)) + } + + override fun setMarginCropSymmetric(symmetric: Boolean) { + val next = if (symmetric) { + currentMarginCrop().copy(symmetric = true).withPairsAveraged() + } else { + currentMarginCrop().copy(symmetric = false) + } + updateMarginCropState(next) + } + + override fun setMarginCropShowWarning(show: Boolean) { + updateMarginCropState(currentMarginCrop().copy(showWarning = show)) + } + + override fun setMarginCropDialogVisible(visible: Boolean) { + _uiState.update { it.copy(showMarginCropDialog = visible) } + } + + private fun currentMarginCrop(): ReaderMarginCrop = ReaderMarginCrop( + enabled = _uiState.value.marginCropEnabled, + left = _uiState.value.marginCropLeft, + top = _uiState.value.marginCropTop, + right = _uiState.value.marginCropRight, + bottom = _uiState.value.marginCropBottom, + symmetric = _uiState.value.marginCropSymmetric, + showWarning = _uiState.value.marginCropShowWarning + ) + + private fun updateMarginCropState(crop: ReaderMarginCrop) { + val normalized = crop.withSymmetricEnforced() + _uiState.update { + it.copy( + marginCropEnabled = normalized.enabled, + marginCropLeft = normalized.left, + marginCropTop = normalized.top, + marginCropRight = normalized.right, + marginCropBottom = normalized.bottom, + marginCropSymmetric = normalized.symmetric, + marginCropShowWarning = normalized.showWarning + ) + } + persistMarginCrop( + enabled = normalized.enabled, + left = normalized.left, + top = normalized.top, + right = normalized.right, + bottom = normalized.bottom, + symmetric = normalized.symmetric, + showWarning = normalized.showWarning + ) + } + + private fun persistMarginCrop( + enabled: Boolean, + left: Float, + top: Float, + right: Float, + bottom: Float, + symmetric: Boolean = _uiState.value.marginCropSymmetric, + showWarning: Boolean = _uiState.value.marginCropShowWarning + ) { + viewModelScope.launch { + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, enabled) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, left) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, top) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, right) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, bottom) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_SYMMETRIC, symmetric) + readerPreferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_SHOW_WARNING, showWarning) + } } // ── TTS settings ────────────────────────────────────────────────────── @@ -634,6 +752,10 @@ class ReaderSettingsController( _uiState.update { it.copy(chromeShowAutoScrollIcon = visible) } viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_CHROME_SHOW_AUTO_SCROLL, visible) } } + ReaderChromeButton.CROP -> { + _uiState.update { it.copy(chromeShowCropIcon = visible) } + viewModelScope.launch { readerPreferences.set(PreferencesKeys.READER_CHROME_SHOW_CROP, visible) } + } } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt index d6eef9889..1344ad1d8 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderStyleTab.kt @@ -1,6 +1,7 @@ package io.leostrange.mrcomic.feature.reader.ui import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -15,16 +16,19 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.text.font.FontWeight import io.leostrange.mrcomic.core.ui.theme.style +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetEntry import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSlot import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSnapshot import io.leostrange.mrcomic.feature.reader.domain.preset.parseReaderStylePreset +import io.leostrange.mrcomic.feature.reader.ui.gesture.ReaderColorScheme @OptIn(ExperimentalLayoutApi::class) @Composable @@ -69,10 +73,10 @@ internal fun ReaderStyleTab( var chromeEditorTab by remember { mutableStateOf(ReaderChromeEditorTab.VISIBILITY) } val configurableChromeButtons = remember(uiState.chromeIconOrder) { ReaderChromeButton.resolveOrder(uiState.chromeIconOrder) - .filterNot { it == ReaderChromeButton.STYLE } - } - val supportsMarginCrop = remember(uiState.comic?.format, isTextReader) { - !isTextReader && (uiState.comic?.format == ComicFormat.PDF || uiState.comic?.format == ComicFormat.DJVU) + // The graphic reader must keep the style/settings entry: it is + // the only route to image scaling, crop and graphic presets. + // Text-reader controls keep the style entry in their own tab. + .filterNot { isTextReader && it == ReaderChromeButton.STYLE } } val availableFonts = remember(context, fontCatalogVersion) { ReaderTextFontCatalog.availableFontFamilies(context) @@ -203,33 +207,21 @@ internal fun ReaderStyleTab( } } } - if (supportsMarginCrop) { - item { - Text( - text = readerMarginCropHint(strings.languageCode), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - item { - ReaderSliderRow( - title = readerMarginCropHorizontalLabel(uiState.imageMarginCropHorizontal, strings.languageCode), - valueText = "${(uiState.imageMarginCropHorizontal * 100f).toInt()}%", - value = uiState.imageMarginCropHorizontal, - valueRange = 0f..0.18f, - steps = 17, - onValueChange = onImageMarginCropHorizontalChange - ) - } - item { - ReaderSliderRow( - title = readerMarginCropVerticalLabel(uiState.imageMarginCropVertical, strings.languageCode), - valueText = "${(uiState.imageMarginCropVertical * 100f).toInt()}%", - value = uiState.imageMarginCropVertical, - valueRange = 0f..0.18f, - steps = 17, - onValueChange = onImageMarginCropVerticalChange - ) + item { ReaderSectionTitle(readerText.colorSchemeTitle) } + item { + LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + items(ReaderColorScheme.graphicQuickChoices) { id -> + val label = when (id) { + "SEPIA" -> readerText.sepia + "NIGHT" -> readerText.night + else -> readerText.day + } + ReaderChoiceChip( + selected = uiState.graphicColorScheme == id, + onClick = { onColorSchemeChange(id) }, + label = { Text(label, style = MaterialTheme.typography.labelSmall) } + ) + } } } item { ReaderSectionTitle(readerText.colorSchemeTitle) } @@ -528,6 +520,115 @@ internal fun ReaderStyleTab( } } +/** Shows the retained image area and the exact horizontal/vertical crop zones. */ +@Composable +private fun ReaderGraphicCropPreview( + leftCrop: Float, + topCrop: Float, + rightCrop: Float, + bottomCrop: Float, + language: String +) { + val maxFraction = 0.22f + val left = leftCrop.coerceIn(0f, maxFraction) + val top = topCrop.coerceIn(0f, maxFraction) + val right = rightCrop.coerceIn(0f, maxFraction) + val bottom = bottomCrop.coerceIn(0f, maxFraction) + val previewLabel = when (language) { + "en" -> "Preview · retained image area" + "ja" -> "プレビュー・表示領域" + "zh" -> "预览 · 保留图像区域" + "ko" -> "미리보기 · 표시 영역" + else -> "Предпросмотр · видимая область" + } + ReaderSettingsCard { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(previewLabel, style = MaterialTheme.typography.labelLarge) + Text( + text = "←${(left * 100f).toInt()}% ↑${(top * 100f).toInt()}% ↓${(bottom * 100f).toInt()}% →${(right * 100f).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + MrComicPreviewBackdrop(shape = RoundedCornerShape(12.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(128.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.72f) + .padding(horizontal = 12.dp, vertical = 10.dp) + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + repeat(5) { index -> + Box( + modifier = Modifier + .fillMaxWidth(if (index == 1) 0.78f else 1f) + .height(6.dp) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + RoundedCornerShape(50) + ) + ) + } + } + } + val overlay = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + if (left > 0f) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(left / maxFraction) + .align(Alignment.CenterStart) + .background(overlay) + ) + } + if (right > 0f) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(right / maxFraction) + .align(Alignment.CenterEnd) + .background(overlay) + ) + } + if (top > 0f) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(top / maxFraction) + .align(Alignment.TopCenter) + .background(overlay) + ) + } + if (bottom > 0f) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(bottom / maxFraction) + .align(Alignment.BottomCenter) + .background(overlay) + ) + } + } + } + } + } +} + @Composable internal fun ReaderStylePresetListItem( slot: ReaderStylePresetSlot, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt index 2592a41cc..cb7555518 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicy.kt @@ -3,18 +3,43 @@ package io.leostrange.mrcomic.feature.reader.ui internal data class ReaderTextChromeLayoutInsets( val topCssPx: Int, val bottomCssPx: Int, + /** Fixed physical gutter kept outside the WebView content viewport. */ + val outerTopPx: Int, + val outerBottomPx: Int, ) +/** + * Returns the stable physical reserve for paged text. Two line-height steps + * are kept outside the WebView so a chrome overlay cannot change pagination. + */ +internal fun readerTextTwoLineGutterPx(lineHeightPx: Int): Int = + (lineHeightPx.coerceAtLeast(8) * 2).coerceAtLeast(16) + /** * Reader chrome is an overlay and must not change text wrapping or page - * boundaries. Safe system bars and the persistent one-line text gutter are - * handled by the Compose modifier around the WebView. + * boundaries. The persistent text gutter is kept symmetrically outside the + * WebView. The measured chrome bounds are intentionally ignored because + * chrome is overlay. */ internal fun resolveReaderTextChromeLayoutInsets( measuredTopCssPx: Int, measuredBottomCssPx: Int, + persistentGutterCssPx: Int = 0, + persistentGutterPx: Int = persistentGutterCssPx, ): ReaderTextChromeLayoutInsets { require(measuredTopCssPx >= 0) require(measuredBottomCssPx >= 0) - return ReaderTextChromeLayoutInsets(topCssPx = 0, bottomCssPx = 0) + require(persistentGutterCssPx >= 0) + require(persistentGutterPx >= 0) + val gutterPx = persistentGutterPx.coerceAtLeast(0) + // Keep parameters validated for the geometry contract, but never feed the + // panel height into reflow: opening chrome must not move the current page. + // The gutter belongs to the Compose viewport, not the document. This keeps + // the WebView's page budget identical with chrome hidden and shown. + return ReaderTextChromeLayoutInsets( + topCssPx = 0, + bottomCssPx = 0, + outerTopPx = gutterPx, + outerBottomPx = gutterPx, + ) } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt index 7a8d092a9..7f64f4e3a 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderUiState.kt @@ -36,8 +36,7 @@ internal const val DEFAULT_TEXT_WORD_SPACING = 0f internal const val DEFAULT_TEXT_PARAGRAPH_SPACING = 0.2f internal const val DEFAULT_TEXT_ALIGNMENT = "left" internal const val DEFAULT_TEXT_BOLD = false -internal const val DEFAULT_IMAGE_MARGIN_CROP_HORIZONTAL = 0f -internal const val DEFAULT_IMAGE_MARGIN_CROP_VERTICAL = 0f +internal const val DEFAULT_IMAGE_MARGIN_CROP = 0f internal const val DEFERRED_PAGE_COUNT_START_DELAY_MILLIS = 1_500L internal const val TAG = "ReaderViewModel" internal val FOOTNOTE_MARKER_RE = Regex( @@ -93,10 +92,18 @@ data class ReaderUiState( val autoScrollPauseReasons: Set = emptySet(), /** How graphic pages should be fitted on the reader canvas. */ val imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - /** Symmetric left/right crop for document page margins. */ - val imageMarginCropHorizontal: Float = DEFAULT_IMAGE_MARGIN_CROP_HORIZONTAL, - /** Symmetric top/bottom crop for document page margins. */ - val imageMarginCropVertical: Float = DEFAULT_IMAGE_MARGIN_CROP_VERTICAL, + /** Per-side document margin crop ("обрезка пустых полей"). */ + val marginCropEnabled: Boolean = false, + val marginCropLeft: Float = DEFAULT_IMAGE_MARGIN_CROP, + val marginCropTop: Float = DEFAULT_IMAGE_MARGIN_CROP, + val marginCropRight: Float = DEFAULT_IMAGE_MARGIN_CROP, + val marginCropBottom: Float = DEFAULT_IMAGE_MARGIN_CROP, + /** Symmetric mode keeps left == right and top == bottom while editing. */ + val marginCropSymmetric: Boolean = true, + /** Show the on-page warning while crop is active. */ + val marginCropShowWarning: Boolean = true, + /** Whether the margin-crop dialog is currently open. */ + val showMarginCropDialog: Boolean = false, /** Number of pages to preload ahead of the current page */ val preloadPages: Int = 3, /** @@ -189,6 +196,8 @@ data class ReaderUiState( val epubAccumulatedTotalPages: Int = 0, /** Accumulated current visual page position across all visited EPUB sections. */ val epubAccumulatedCurrentPage: Int = 0, + /** Whether all sections in the book have completed visual pagination. */ + val isTextPaginationResolved: Boolean = false, /** Whether hardware volume buttons should turn pages inside the reader. */ val volumeKeysPagingEnabled: Boolean = false, /** System TTS defaults used by the reader services tab. */ @@ -251,7 +260,9 @@ data class ReaderUiState( val chromeShowDirectionIcon: Boolean = true, val chromeShowTranslateIcon: Boolean = true, val chromeShowBrightnessIcon: Boolean = true, - val chromeShowAutoScrollIcon: Boolean = true + val chromeShowAutoScrollIcon: Boolean = true, + /** Whether the margin-crop button appears in the top chrome bar. */ + val chromeShowCropIcon: Boolean = true ) { /** * BUG-READER-01: Unified effective total pages. diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt index 6b14eae42..0a161942f 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderViewModel.kt @@ -268,7 +268,8 @@ class ReaderViewModel @Inject constructor( markReaderPresetCustom = { settingsController.markReaderPresetCustom() }, getLastTextWebtoonCursor = { navigationController.lastTextWebtoonCursor }, seedTextWebtoonCursor = { navigationController.seedTextWebtoonCursor(it) }, - onAutoScrollModeChanged = { mode -> autoScrollSettingsController.switchMode(mode) } + onAutoScrollModeChanged = { mode -> autoScrollSettingsController.switchMode(mode) }, + savePositionImmediate = { progressController.savePositionImmediate() } ) private val openGuard = io.leostrange.mrcomic.feature.reader.domain.session.ReaderOpenGuard() @@ -370,7 +371,8 @@ class ReaderViewModel @Inject constructor( sectionPageCounts = sectionPageCountSnapshot, sectionIndex = sectionIndex, sectionPageIndex = safePageIndex, - totalSections = progressController.totalBookSections + totalSections = progressController.totalBookSections, + stableEstimateOverride = progressController.sectionPageCounts.stableEstimate() ) _uiState.update { it.copy( @@ -378,7 +380,8 @@ class ReaderViewModel @Inject constructor( sectionCurrentPage = safePageIndex, sectionCharacterOffset = safeCharacterOffset, epubAccumulatedTotalPages = progress.accumulatedTotalPages, - epubAccumulatedCurrentPage = progress.accumulatedCurrentPage + epubAccumulatedCurrentPage = progress.accumulatedCurrentPage, + isTextPaginationResolved = progress.isResolved ) } } @@ -405,6 +408,16 @@ class ReaderViewModel @Inject constructor( } } + /** + * BUG-VERTICAL-01: Called by raster WebtoonView when scroll progression changes. + * Updates the seekbar position to match the actual scroll position. + */ + internal fun onRasterWebtoonScrollProgressionChanged(progression: Float) { + _uiState.update { + it.copy(rasterWebtoonScrollProgression = progression.toDouble()) + } + } + fun tocDisplayPage(enginePageIndex: Int): Int = pageCacheController.tocDisplayPage(enginePageIndex) @@ -432,12 +445,23 @@ class ReaderViewModel @Inject constructor( private suspend fun localizedReaderText(): ReaderUiText = readerUiText(readerLanguageCode()) override fun onCleared() { - // Snapshot the pending IO work, then run it on an application-scoped coroutine so leaving - // the reader never blocks the main thread. These paths (progress save, session close) only - // touch Room/DataStore/engine registry — all independent of the resources torn down below — - // so completing them slightly after onCleared returns is safe. Previously three runBlocking - // calls here caused an ANR on slow storage. - progressController.emitReaderClosed(appScope) + // BUG-READER-03: flush progress synchronously before tearing down resources. + // Previously two separate appScope.launch {} fire-and-forget coroutines could both + // lose the race against a fast process kill. One runBlocking(Dispatchers.IO) is + // acceptable here — Room's suspend DAO already dispatches to its internal IO pool + // and the write is a single UPDATE on a single row (~1 ms in the typical case). + // The old ANR was caused by THREE sequential runBlocking calls; this is one. + progressController.stopPeriodicPositionSave() + val closedAnalytics = progressController.emitReaderClosed() + progressController.progressSaveJob?.cancel() + try { + kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { + progressController.flushPendingProgressSave() + } + } catch (e: Exception) { + Log.e("ReaderViewModel", "Failed to flush progress on close", e) + } + closedAnalytics?.let { progressController.trackReaderClosed(it, appScope) } super.onCleared() openingController.cancelPendingOpen() pageCacheController.cancelPendingToc() @@ -450,11 +474,8 @@ class ReaderViewModel @Inject constructor( // is a no-op and matching reset() runs _after_ the cleanup. sessionLifecycleCoordinator.beginClose() textReaderOrchestrator.cancelAllJobs() - progressController.progressSaveJob?.cancel() sessionManager.closeReaderResources() appScope.launch { - runCatching { progressController.flushPendingProgressSave() } - .onFailure { Log.e("ReaderViewModel", "Failed to flush progress on close", it) } runCatching { sessionManager.closeBookSessionAsync() } .onFailure { Log.w(TAG, "Failed to close book session on close", it) } // markClosed throws if the ledger was Opening when beginClose() diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt index a5189676f..46eee9633 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebView.kt @@ -605,6 +605,37 @@ internal class ReaderWebView(context: android.content.Context) : WebView(context else{target.scrollIntoView({block:'start',inline:'nearest'});} return true; } + if($sectionIndex>=0){ + var secTarget=document.querySelector('.mrcomic-text-webtoon-section[data-mrcomic-page-index="'+$sectionIndex+'"]'); + if(secTarget){ + if($characterOffset>0){ + var walker=document.createTreeWalker(secTarget,NodeFilter.SHOW_TEXT,null); + var remaining=$characterOffset; + var node=null; + while((node=walker.nextNode())){ + var length=(node.nodeValue||'').length; + if(remaining<=length)break; + remaining-=length; + } + if(node){ + var range=document.createRange(); + var start=Math.max(0,Math.min((node.nodeValue||'').length,remaining)); + range.setStart(node,start); + range.setEnd(node,Math.min((node.nodeValue||'').length,start+1)); + var rect=range.getBoundingClientRect(); + if(rect&&isFinite(rect.top)){ + window.scrollBy(0,Math.round(rect.top-16)); + range.detach&&range.detach(); + return true; + } + range.detach&&range.detach(); + } + } + if(window.__mrcomicScrollToAnchor){window.__mrcomicScrollToAnchor(secTarget);} + else{secTarget.scrollIntoView({block:'start',inline:'nearest'});} + return true; + } + } if($characterOffset>=0){ var content=document.querySelector($characterScopeSelector)|| document.querySelector('[data-mrcomic-text-webtoon-document]')||document.body; @@ -636,14 +667,6 @@ internal class ReaderWebView(context: android.content.Context) : WebView(context window.scrollTo(0,Math.round(max*$progression)); return true; } - if($sectionIndex>=0){ - target=document.querySelector('.mrcomic-text-webtoon-section[data-mrcomic-page-index="'+$sectionIndex+'"]'); - } - if(target){ - if(window.__mrcomicScrollToAnchor){window.__mrcomicScrollToAnchor(target);} - else{target.scrollIntoView({block:'start',inline:'nearest'});} - return true; - } return false; }catch(e){return false;} })() diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt index 6a4e6dbc5..6d1589b37 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewLoadController.kt @@ -36,6 +36,7 @@ internal class ReaderWebViewLoadController { is ReaderWebViewRuntimeEvent.DocumentCommitted -> handleCommitted(event.generation) is ReaderWebViewRuntimeEvent.LayoutReady -> handleLayoutReady(event) is ReaderWebViewRuntimeEvent.RestoreAcknowledged -> handleRestoreAcknowledged(event.generation) + is ReaderWebViewRuntimeEvent.RestoreRejected -> handleRestoreRejected(event.generation) is ReaderWebViewRuntimeEvent.LoadFailed -> handleFailure(event.generation, event.reason.ifBlank { "load failed" }) is ReaderWebViewRuntimeEvent.ContentBlank -> handleFailure(event.generation, blankReason()) ReaderWebViewRuntimeEvent.Disposed -> { @@ -101,9 +102,16 @@ internal class ReaderWebViewLoadController { if (runtimeState.restoreIssued) return emptyList() runtimeState = runtimeState.copy( phase = ReaderWebViewRuntimePhase.RESTORING, - restoreIssued = true + restoreIssued = true, + restoreAttempt = FIRST_RESTORE_ATTEMPT + ) + return listOf( + ReaderWebViewRuntimeEffect.Restore( + runtimeState.generation, + restoreTarget, + FIRST_RESTORE_ATTEMPT + ) ) - return listOf(ReaderWebViewRuntimeEffect.Restore(runtimeState.generation, restoreTarget)) } if (runtimeState.phase == ReaderWebViewRuntimePhase.READY) return emptyList() runtimeState = runtimeState.copy(phase = ReaderWebViewRuntimePhase.READY) @@ -119,6 +127,24 @@ internal class ReaderWebViewLoadController { return listOf(ReaderWebViewRuntimeEffect.PublishReady(generation, metrics)) } + private fun handleRestoreRejected(generation: Long): List { + if (!isActive(generation) || runtimeState.phase != ReaderWebViewRuntimePhase.RESTORING) { + return emptyList() + } + val target = runtimeState.restoreTarget ?: return emptyList() + if (runtimeState.restoreAttempt >= MAX_RESTORE_ATTEMPTS) { + val metrics = runtimeState.layoutMetrics ?: return emptyList() + runtimeState = runtimeState.copy( + phase = ReaderWebViewRuntimePhase.READY, + error = "restore target unavailable" + ) + return listOf(ReaderWebViewRuntimeEffect.PublishReady(generation, metrics)) + } + val nextAttempt = runtimeState.restoreAttempt + 1 + runtimeState = runtimeState.copy(restoreAttempt = nextAttempt) + return listOf(ReaderWebViewRuntimeEffect.Restore(generation, target, nextAttempt)) + } + private fun handleFailure( generation: Long, reason: String @@ -131,6 +157,7 @@ internal class ReaderWebViewLoadController { committed = false, layoutMetrics = null, restoreIssued = false, + restoreAttempt = 0, error = reason ) return listOf( @@ -222,5 +249,7 @@ internal class ReaderWebViewLoadController { private companion object { const val PRIMARY_LOAD_ATTEMPT = 1 const val FALLBACK_LOAD_ATTEMPT = 2 + const val FIRST_RESTORE_ATTEMPT = 1 + const val MAX_RESTORE_ATTEMPTS = 5 } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt index 40fd72a9f..ba2befebc 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeEffect.kt @@ -9,7 +9,8 @@ internal sealed interface ReaderWebViewRuntimeEffect { data class Restore( val generation: Long, - val target: ReaderWebViewRestoreTarget + val target: ReaderWebViewRestoreTarget, + val attempt: Int = 1 ) : ReaderWebViewRuntimeEffect data class PublishReady( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt index 6d215c85a..9c4859448 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeOwner.kt @@ -64,10 +64,12 @@ internal class ReaderWebViewRuntimeOwner( webView.loadInlineFallbackNow() } } - is ReaderWebViewRuntimeEffect.Restore -> webView.restoreRuntimeTarget( - generation = effect.generation, - target = effect.target - ) { restored -> + is ReaderWebViewRuntimeEffect.Restore -> { + val restore = { + webView.restoreRuntimeTarget( + generation = effect.generation, + target = effect.target + ) { restored -> if (!restored) { Log.w(HTML_READER_TAG, "Restore target was not found for generation=${effect.generation}") } else { @@ -77,11 +79,18 @@ internal class ReaderWebViewRuntimeOwner( executeEffects( webView = webView, effects = loadController.dispatch( - ReaderWebViewRuntimeEvent.RestoreAcknowledged(effect.generation) + if (restored) { + ReaderWebViewRuntimeEvent.RestoreAcknowledged(effect.generation) + } else { + ReaderWebViewRuntimeEvent.RestoreRejected(effect.generation) + } ), onConsumeAnchor = onConsumeAnchor, onConsumeSection = onConsumeSection ) + } + } + if (effect.attempt == 1) restore() else webView.postDelayed(restore, RESTORE_RETRY_DELAY_MS) } is ReaderWebViewRuntimeEffect.PublishReady -> Unit is ReaderWebViewRuntimeEffect.ShowTerminalError -> { @@ -91,4 +100,8 @@ internal class ReaderWebViewRuntimeOwner( } } } + + private companion object { + const val RESTORE_RETRY_DELAY_MS = 80L + } } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt index 27d64c80e..99f8ad22c 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeState.kt @@ -49,6 +49,7 @@ internal data class ReaderWebViewRuntimeState( val layoutMetrics: ReaderWebViewLayoutMetrics? = null, val restoreTarget: ReaderWebViewRestoreTarget? = null, val restoreIssued: Boolean = false, + val restoreAttempt: Int = 0, val error: String? = null ) @@ -66,6 +67,7 @@ internal sealed interface ReaderWebViewRuntimeEvent { ) : ReaderWebViewRuntimeEvent data class RestoreAcknowledged(val generation: Long) : ReaderWebViewRuntimeEvent + data class RestoreRejected(val generation: Long) : ReaderWebViewRuntimeEvent data class LoadFailed(val generation: Long, val reason: String) : ReaderWebViewRuntimeEvent data class ContentBlank(val generation: Long) : ReaderWebViewRuntimeEvent data object Disposed : ReaderWebViewRuntimeEvent diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt index 95bb5234d..14c09f2be 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/TextBookSessionBridge.kt @@ -40,7 +40,13 @@ internal object TextBookSessionBridge { val title = item.title.trim() if (title.isNotBlank()) { resolveBookTocPageIndex(item, reader)?.let { pageIndex -> - entries += TocEntry(title = title, pageIndex = pageIndex) + val anchorId = item.locator?.fragment?.takeIf { it.isNotBlank() } + entries += TocEntry( + title = title, + pageIndex = pageIndex, + anchorId = anchorId, + sectionIndex = pageIndex + ) } } if (item.children.isNotEmpty()) { diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt index d504a26ff..cfdb604aa 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/PageView.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme @@ -53,19 +54,22 @@ fun PageView( onCenterTap: () -> Unit, onUserTouchChange: (Boolean) -> Unit = {}, imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - marginCropHorizontal: Float = 0f, - marginCropVertical: Float = 0f, + marginCropLeft: Float = 0f, + marginCropTop: Float = 0f, + marginCropRight: Float = 0f, + marginCropBottom: Float = 0f, modifier: Modifier = Modifier ) { val isEInk = LocalEInkMode.current val isDualPage = uiState.readingMode == ReadingMode.DUAL_PAGE + val graphicPageOffset = readerGraphicPageOffset(uiState.comic?.format) val leftPage = uiState.currentPage - val imageCrop = remember(marginCropHorizontal, marginCropVertical) { - ReaderImageCrop( - horizontalFraction = marginCropHorizontal, - verticalFraction = marginCropVertical - ) - } + val imageCrop = ReaderImageCrop( + leftFraction = marginCropLeft, + topFraction = marginCropTop, + rightFraction = marginCropRight, + bottomFraction = marginCropBottom + ) AnimatedContent( targetState = Pair(leftPage, isDualPage), @@ -123,6 +127,8 @@ fun PageView( alignment = Alignment.CenterEnd, imageScaleMode = imageScaleMode, crop = imageCrop, + pageOffsetX = graphicPageOffset.xDp.dp, + pageOffsetY = graphicPageOffset.yDp.dp, modifier = Modifier.weight(1f) ) if (rightPage != null) { @@ -132,6 +138,8 @@ fun PageView( alignment = Alignment.CenterStart, imageScaleMode = imageScaleMode, crop = imageCrop, + pageOffsetX = graphicPageOffset.xDp.dp, + pageOffsetY = graphicPageOffset.yDp.dp, modifier = Modifier.weight(1f) ) } else { @@ -164,6 +172,8 @@ fun PageView( contentDescription = "Page ${page + 1}", imageScaleMode = imageScaleMode, crop = imageCrop, + pageOffsetX = graphicPageOffset.xDp.dp, + pageOffsetY = graphicPageOffset.yDp.dp, modifier = Modifier.fillMaxSize() ) } @@ -179,7 +189,9 @@ private fun PagePane( modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - crop: ReaderImageCrop = ReaderImageCrop() + crop: ReaderImageCrop = ReaderImageCrop(), + pageOffsetX: androidx.compose.ui.unit.Dp = 0.dp, + pageOffsetY: androidx.compose.ui.unit.Dp = 0.dp ) { BoxWithConstraints(modifier = modifier.clipToBounds(), contentAlignment = alignment) { if (bitmap == null) { @@ -191,7 +203,13 @@ private fun PagePane( val density = LocalDensity.current val containerWidthPx = with(density) { maxWidth.toPx().coerceAtLeast(1f) } val containerHeightPx = with(density) { maxHeight.toPx().coerceAtLeast(1f) } - val (sourceWidthPx, sourceHeightPx) = remember(bitmap, crop.normalizedHorizontal, crop.normalizedVertical) { + val (sourceWidthPx, sourceHeightPx) = remember( + bitmap, + crop.normalizedLeft, + crop.normalizedTop, + crop.normalizedRight, + crop.normalizedBottom + ) { croppedSourceDimensions(bitmap, crop) } val (baseWidthPx, baseHeightPx) = remember( @@ -222,6 +240,7 @@ private fun PagePane( modifier = Modifier .width(imageWidth) .height(imageHeight) + .offset(x = pageOffsetX, y = pageOffsetY) ) { CroppedBitmapImage( bitmap = bitmap, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt index 30ec24e7c..0cbcbe3ff 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomBar.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.ui.locale.LocalStrings @@ -31,8 +32,15 @@ fun ReaderBottomBar( chapterTitle: String? = null, epubAccumulatedTotalPages: Int = 0, epubAccumulatedCurrentPage: Int = 0, + isTextPaginationResolved: Boolean = true, + isTextWebtoon: Boolean = false, + freeScrollProgression: Double = -1.0, + /** BUG-VERTICAL-01: Raster webtoon scroll progression for seekbar sync. */ + rasterWebtoonScrollProgression: Double = -1.0, onReadingModeChange: (ReadingMode) -> Unit, onPageChange: (Int) -> Unit, + onProgressionChange: ((Float) -> Unit)? = null, + showReadingModeControls: Boolean = true, modifier: Modifier = Modifier ) { val strings = LocalStrings.current @@ -48,10 +56,14 @@ fun ReaderBottomBar( sectionPageCount = sectionPageCount, epubAccumulatedCurrentPage = epubAccumulatedCurrentPage, epubAccumulatedTotalPages = epubAccumulatedTotalPages, + isTextPaginationResolved = isTextPaginationResolved, ) val effectiveTotalPages = effectiveProgress.totalPages val effectiveCurrentPage = effectiveProgress.currentPage - val bookProgress = if (effectiveTotalPages > 0) ((effectiveCurrentPage + 1) * 100f / effectiveTotalPages).toInt() else 0 + // BUG-READER-04: Use same formula as database: currentPage / (pageCount - 1) + val bookProgress = if (effectiveTotalPages > 1) { + (effectiveCurrentPage.toFloat() / (effectiveTotalPages - 1) * 100f).toInt().coerceIn(0, 100) + } else 0 Column( modifier = modifier @@ -76,7 +88,13 @@ fun ReaderBottomBar( style = MaterialTheme.typography.labelMedium ) Text( - text = if (showSectionPage) "${sectionCurrentPage + 1}/$sectionPageCount" else "${currentPage + 1} / $totalPages", + text = if (showSectionPage && effectiveTotalPages <= sectionPageCount) { + "${sectionCurrentPage + 1}/$sectionPageCount" + } else if (!effectiveProgress.isResolved) { + "${effectiveCurrentPage + 1} / …" + } else { + "${effectiveCurrentPage + 1} / $effectiveTotalPages" + }, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium ) @@ -87,7 +105,9 @@ fun ReaderBottomBar( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) ) { - if (isLandscape && !isTextBook) { + if (!showReadingModeControls) { + Spacer(Modifier.weight(1f)) + } else if (isLandscape && !isTextBook) { ReaderPanelChip( selected = true, onClick = {}, @@ -110,15 +130,21 @@ fun ReaderBottomBar( Spacer(Modifier.height(12.dp)) if (showPageCountText) { val counterText = when { + chapterTitle != null && !effectiveProgress.isResolved -> + "$chapterTitle (${effectiveCurrentPage + 1}/…)" chapterTitle != null -> "$chapterTitle (${effectiveCurrentPage + 1}/$effectiveTotalPages)" + !effectiveProgress.isResolved -> + "${effectiveCurrentPage + 1} / …" else -> "${effectiveCurrentPage + 1} / $effectiveTotalPages" } Text( text = counterText, color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) if (isTextBook && totalPages > 0) { Spacer(Modifier.height(2.dp)) @@ -134,7 +160,37 @@ fun ReaderBottomBar( } } - if (totalPages > 1) { + if (isTextWebtoon && freeScrollProgression in 0.0..1.0) { + // Continuous slider for text webtoon — uses scroll progression (0.0..1.0) + Slider( + value = freeScrollProgression.toFloat().coerceIn(0f, 1f), + onValueChange = { onProgressionChange?.invoke(it) }, + valueRange = 0f..1f, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outlineVariant, + ) + ) + } else if (!isTextWebtoon && readingMode == ReadingMode.WEBTOON && rasterWebtoonScrollProgression in 0.0..1.0) { + // BUG-VERTICAL-01: Continuous slider for raster webtoon — uses tracked scroll progression. + // Converting progression fraction to page index so the user can scrub through the document. + Slider( + value = rasterWebtoonScrollProgression.toFloat().coerceIn(0f, 1f), + onValueChange = { fraction -> + val targetPage = (fraction * totalPages).toInt().coerceIn(0, (totalPages - 1).coerceAtLeast(0)) + onPageChange(targetPage) + }, + valueRange = 0f..1f, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outlineVariant, + ) + ) + } else if (totalPages > 1) { Slider( value = currentPage.toFloat(), onValueChange = { onPageChange(it.toInt()) }, diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt index 20fa18b8c..63cba5aeb 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicy.kt @@ -3,6 +3,7 @@ package io.leostrange.mrcomic.feature.reader.ui.components internal data class ReaderBottomProgress( val currentPage: Int, val totalPages: Int, + val isResolved: Boolean = true, ) internal fun resolveReaderBottomProgress( @@ -12,17 +13,21 @@ internal fun resolveReaderBottomProgress( sectionPageCount: Int, epubAccumulatedCurrentPage: Int, epubAccumulatedTotalPages: Int, + isTextPaginationResolved: Boolean = true, ): ReaderBottomProgress = when { isTextBook && epubAccumulatedTotalPages > 0 -> ReaderBottomProgress( currentPage = epubAccumulatedCurrentPage.coerceIn(0, epubAccumulatedTotalPages - 1), totalPages = epubAccumulatedTotalPages, + isResolved = isTextPaginationResolved, ) isTextBook && sectionPageCount > 0 -> ReaderBottomProgress( currentPage = currentPage.coerceAtLeast(0), totalPages = totalPages.coerceAtLeast(1), + isResolved = isTextPaginationResolved, ) else -> ReaderBottomProgress( currentPage = currentPage.coerceAtLeast(0), totalPages = totalPages.coerceAtLeast(1), + isResolved = true, ) } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffset.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffset.kt new file mode 100644 index 000000000..0dd981927 --- /dev/null +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffset.kt @@ -0,0 +1,21 @@ +package io.leostrange.mrcomic.feature.reader.ui.components + +import io.leostrange.mrcomic.core.model.ComicFormat + +internal data class ReaderGraphicPageOffset( + val xDp: Int, + val yDp: Int, +) + +/** + * PDF and DJVU pages use the same vertical baseline as the original document + * viewport. Horizontal placement remains neutral because [PagePane] already + * centers the page in its viewport. Other raster formats keep the neutral + * centered placement as well. + */ +internal fun readerGraphicPageOffset(format: ComicFormat?): ReaderGraphicPageOffset = + if (format == ComicFormat.PDF || format == ComicFormat.DJVU) { + ReaderGraphicPageOffset(xDp = 0, yDp = 20) + } else { + ReaderGraphicPageOffset(xDp = 0, yDp = 0) + } diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt index 1a9a2f439..0208a23c1 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderImageCrop.kt @@ -16,12 +16,37 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import kotlin.math.roundToInt +/** + * Per-side page crop as fractions of the page size. The legacy symmetric + * constructor (horizontalFraction/verticalFraction) is kept for call sites + * that crop both sides of an axis equally. + */ internal data class ReaderImageCrop( - val horizontalFraction: Float = 0f, - val verticalFraction: Float = 0f + val leftFraction: Float, + val topFraction: Float, + val rightFraction: Float, + val bottomFraction: Float ) { - val normalizedHorizontal: Float = horizontalFraction.coerceIn(0f, 0.22f) - val normalizedVertical: Float = verticalFraction.coerceIn(0f, 0.22f) + constructor(horizontalFraction: Float = 0f, verticalFraction: Float = 0f) : this( + leftFraction = horizontalFraction, + topFraction = verticalFraction, + rightFraction = horizontalFraction, + bottomFraction = verticalFraction + ) + + val normalizedLeft: Float = leftFraction.coerceIn(0f, MAX_CROP_FRACTION) + val normalizedTop: Float = topFraction.coerceIn(0f, MAX_CROP_FRACTION) + val normalizedRight: Float = rightFraction.coerceIn(0f, MAX_CROP_FRACTION) + val normalizedBottom: Float = bottomFraction.coerceIn(0f, MAX_CROP_FRACTION) + + val isZero: Boolean + get() = normalizedLeft <= 0f && normalizedTop <= 0f && + normalizedRight <= 0f && normalizedBottom <= 0f + + companion object { + const val MAX_CROP_FRACTION = 0.22f + val None = ReaderImageCrop(0f, 0f, 0f, 0f) + } } internal fun croppedSourceDimensions( @@ -44,7 +69,13 @@ internal fun CroppedBitmapImage( ) { val layoutDirection = LocalLayoutDirection.current val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() } - val sourceRect = remember(bitmap, crop.normalizedHorizontal, crop.normalizedVertical) { + val sourceRect = remember( + bitmap, + crop.normalizedLeft, + crop.normalizedTop, + crop.normalizedRight, + crop.normalizedBottom + ) { croppedSourceRect(bitmap, crop) } Canvas( @@ -89,15 +120,19 @@ private fun croppedSourceRect( bitmap: Bitmap, crop: ReaderImageCrop ): CroppedSourceRect { - val horizontalInset = (bitmap.width * crop.normalizedHorizontal).roundToInt() + val leftInset = (bitmap.width * crop.normalizedLeft).roundToInt() .coerceIn(0, (bitmap.width - 1) / 2) - val verticalInset = (bitmap.height * crop.normalizedVertical).roundToInt() + val rightInset = (bitmap.width * crop.normalizedRight).roundToInt() + .coerceIn(0, (bitmap.width - 1) / 2) + val topInset = (bitmap.height * crop.normalizedTop).roundToInt() + .coerceIn(0, (bitmap.height - 1) / 2) + val bottomInset = (bitmap.height * crop.normalizedBottom).roundToInt() .coerceIn(0, (bitmap.height - 1) / 2) - val width = (bitmap.width - horizontalInset * 2).coerceAtLeast(1) - val height = (bitmap.height - verticalInset * 2).coerceAtLeast(1) + val width = (bitmap.width - leftInset - rightInset).coerceAtLeast(1) + val height = (bitmap.height - topInset - bottomInset).coerceAtLeast(1) return CroppedSourceRect( - left = horizontalInset, - top = verticalInset, + left = leftInset, + top = topInset, width = width, height = height ) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt index 2bdc4cfd8..a8e095035 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/components/WebtoonView.kt @@ -54,8 +54,10 @@ fun WebtoonView( onRightTap: () -> Unit, onCenterTap: () -> Unit, imageScaleMode: String = ReaderImageScaleMode.FIT_WIDTH.storedValue, - marginCropHorizontal: Float = 0f, - marginCropVertical: Float = 0f, + marginCropLeft: Float = 0f, + marginCropTop: Float = 0f, + marginCropRight: Float = 0f, + marginCropBottom: Float = 0f, modifier: Modifier = Modifier ) { val isEInk = LocalEInkMode.current @@ -80,12 +82,12 @@ fun WebtoonView( onStop = { viewModel.autoScrollRuntimeController.stop() } ) - val imageCrop = remember(marginCropHorizontal, marginCropVertical) { - ReaderImageCrop( - horizontalFraction = marginCropHorizontal, - verticalFraction = marginCropVertical - ) - } + val imageCrop = ReaderImageCrop( + leftFraction = marginCropLeft, + topFraction = marginCropTop, + rightFraction = marginCropRight, + bottomFraction = marginCropBottom + ) val webtoonPreloadBehind = 1 val webtoonPreloadAhead = 3 @@ -104,21 +106,49 @@ fun WebtoonView( viewModel.pageLoader.preloadWebtoonWindow(pages) } - // User scrolled the list в†’ update the ViewModel's current page. - // snapshotFlow + distinctUntilChanged prevents re-entrancy: the emission - // only fires when firstVisibleItemIndex *actually* changes, and navigateTo() - // updating uiState.currentPage does NOT scroll the list here (that's the - // second effect below), so there is no feedback loop. + // Guard against feedback loop: suppress scroll->page sync while applying external page->scroll. + var suppressScrollSync by remember(uiState.comic?.id) { mutableStateOf(false) } + + // User scrolled the list -> update the ViewModel’s current page. LaunchedEffect(listState, uiState.comic?.id) { snapshotFlow { listState.firstVisibleItemIndex } .distinctUntilChanged() - .collect { index -> viewModel.navigationController.navigateTo(index, ReaderNavigationProgressSource.JUMP) } + .collect { index -> + if (!suppressScrollSync) { + viewModel.navigationController.navigateTo(index, ReaderNavigationProgressSource.JUMP) + } + } } - // External page change (e.g. bottom bar slider) в†’ scroll the list. + // BUG-VERTICAL-01: Track scroll progression (0..1) for seekbar synchronization. + LaunchedEffect(listState, uiState.comic?.id, uiState.totalPages) { + snapshotFlow { + val totalItems = uiState.totalPages.coerceAtLeast(1) + val firstVisible = listState.firstVisibleItemIndex + val scrollOffset = listState.firstVisibleItemScrollOffset + val visibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull() + val itemHeight = visibleItem?.size?.coerceAtLeast(1) ?: 1 + val progression = if (totalItems > 0) { + ((firstVisible.toFloat() + scrollOffset.toFloat() / itemHeight) / totalItems) + .coerceIn(0f, 1f) + } else 0f + progression + } + .distinctUntilChanged() + .debounce(50L) + .collect { progression -> + if (!suppressScrollSync) { + viewModel.onRasterWebtoonScrollProgressionChanged(progression) + } + } + } + + // External page change (e.g. bottom bar slider) -> scroll the list. LaunchedEffect(uiState.comic?.id, uiState.currentPage) { if (listState.firstVisibleItemIndex != uiState.currentPage) { + suppressScrollSync = true listState.scrollToItem(uiState.currentPage) + suppressScrollSync = false } if (uiState.totalPages > 0) { val start = (uiState.currentPage - webtoonPreloadBehind).coerceAtLeast(0) @@ -337,7 +367,13 @@ private fun ZoomableFillWidthImage( } else { Float.POSITIVE_INFINITY } - val (sourceWidthPx, sourceHeightPx) = remember(bitmap, crop.normalizedHorizontal, crop.normalizedVertical) { + val (sourceWidthPx, sourceHeightPx) = remember( + bitmap, + crop.normalizedLeft, + crop.normalizedTop, + crop.normalizedRight, + crop.normalizedBottom + ) { croppedSourceDimensions(bitmap, crop) } val imageSize = remember( diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt index 264898a84..dd20a20e1 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometry.kt @@ -39,28 +39,31 @@ data class ReaderViewportGeometry( /** * Top inset for CSS injection (in CSS pixels). - * Accounts for: status bar + cutout + toolbar (if visible) + reader padding. + * Accounts for: toolbar (if visible) + reader padding + safety margin. + * Note: System insets (status bar, cutout) are NOT included — + * they are already handled by Compose WindowInsetsPadding modifier. * VERTICAL-01: Includes safety margin for edge-to-edge mode. */ val contentTopInsetCssPx: Int get() { - val systemInset = maxOf(statusBarInsetPx, displayCutoutInsetPx) val chromeReserve = if (hideToolbarsWhileReading) 0 else topToolbarHeightPx val safetyMarginPx = if (hideToolbarsWhileReading) MIN_SAFETY_MARGIN_PX else 0 - val totalPx = systemInset + chromeReserve + readerTopPaddingPx + safetyMarginPx + val totalPx = chromeReserve + readerTopPaddingPx + safetyMarginPx return (totalPx / densityScale).roundToInt().coerceAtLeast(MIN_INSET_CSS_PX) } /** * Bottom inset for CSS injection (in CSS pixels). - * Accounts for: navigation bar + toolbar (if visible) + reader padding. - * VERTICAL-02: Includes safety margin for edge-to-edge mode. + * Accounts for: toolbar (if visible) + reader padding + safety margin. + * Note: System insets (navigation bar, cutout) are NOT included — + * they are already handled by Compose WindowInsetsPadding modifier. + * BUG-PAGED-02: Use symmetric calculation with top inset. */ val contentBottomInsetCssPx: Int get() { val chromeReserve = if (hideToolbarsWhileReading) 0 else bottomToolbarHeightPx val safetyMarginPx = if (hideToolbarsWhileReading) MIN_SAFETY_MARGIN_PX else 0 - val totalPx = navigationBarInsetPx + chromeReserve + readerBottomPaddingPx + safetyMarginPx + val totalPx = chromeReserve + readerBottomPaddingPx + safetyMarginPx return (totalPx / densityScale).roundToInt().coerceAtLeast(MIN_INSET_CSS_PX) } @@ -77,11 +80,13 @@ data class ReaderViewportGeometry( /** * Bottom inset for paged layout calculation (in physical pixels). + * BUG-PAGED-02: Use symmetric calculation with top inset. */ val contentBottomInsetPx: Int get() { + val systemInset = maxOf(navigationBarInsetPx, displayCutoutInsetPx) val chromeReserve = if (hideToolbarsWhileReading) 0 else bottomToolbarHeightPx - return navigationBarInsetPx + chromeReserve + readerBottomPaddingPx + return systemInset + chromeReserve + readerBottomPaddingPx } /** @@ -104,12 +109,15 @@ data class ReaderViewportGeometry( // ── Chrome-reserve-only CSS insets ───────────────────────────────── // System bars are handled by Compose WindowInsetsPadding modifiers and // the sentence gutter by vertical padding on the text reader modifier; - // only the visible chrome reserve is injected into CSS. When toolbars - // are hidden the reserve (and therefore the CSS inset) is zero. + // only the chrome reserve is injected into CSS. The value is constant + // while toolbars are pinned (auto-hide off), so toggling chrome never + // reflows text (BUG-PAGED-02 / T1/T2). /** * Top chrome-reserve inset in CSS pixels. - * Returns 0 when toolbars are hidden. + * Constant for pinned toolbars regardless of transient visibility. + * Note: System insets (status bar, cutout) are NOT included here — + * they are already handled by Compose WindowInsetsPadding modifier. */ val chromeTopInsetCssPx: Int get() { @@ -120,6 +128,8 @@ data class ReaderViewportGeometry( /** * Bottom chrome-reserve inset in CSS pixels. * Returns 0 when toolbars are hidden. + * Note: System insets (navigation bar, cutout) are NOT included here — + * they are already handled by Compose WindowInsetsPadding modifier. */ val chromeBottomInsetCssPx: Int get() { @@ -129,9 +139,9 @@ data class ReaderViewportGeometry( companion object { /** Minimum safety margin in physical pixels for edge-to-edge mode. */ - private const val MIN_SAFETY_MARGIN_PX = 8 + private const val MIN_SAFETY_MARGIN_PX = 4 /** Minimum CSS inset to prevent text from touching screen edges. */ - private const val MIN_INSET_CSS_PX = 4 + private const val MIN_INSET_CSS_PX = 2 /** * Creates a [ReaderViewportGeometry] from measured Compose values. * diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt index ccb9e953f..1990f535b 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParams.kt @@ -1,6 +1,5 @@ package io.leostrange.mrcomic.feature.reader.ui.gesture -import kotlin.math.floor import kotlin.math.max /** @@ -16,16 +15,15 @@ object PagedLayoutParams { * * This mirrors the JS logic: * ``` - * var rawUsableHeight = Math.max(lineHeight*3, clipHeight - pageInsetTop - pageInsetBottom - Math.max(2, lineHeight*0.12)); - * var usableLineCount = Math.max(3, Math.floor(rawUsableHeight / lineHeight)); - * var usableHeight = Math.max(lineHeight*3, usableLineCount * lineHeight); + * var rawUsableHeight = Math.max(lineHeight*3, clipHeight - pageInsetTop - pageInsetBottom); + * var usableHeight = rawUsableHeight; * ``` * * @param viewportHeightPx Physical viewport height in pixels. * @param topInsetPx Top padding (status bar + chrome) in pixels. * @param bottomInsetPx Bottom padding (navigation bar + chrome) in pixels. * @param lineHeightPx Computed line height in pixels. If 0, defaults to 27 (18sp * 1.5). - * @return Usable page content height in pixels, aligned to line boundaries. + * @return The complete usable page content height in pixels. */ fun calculateUsablePageHeight( viewportHeightPx: Int, @@ -35,10 +33,10 @@ object PagedLayoutParams { ): Int { val lineHeight = if (lineHeightPx > 0f) lineHeightPx else 27f // 18sp * 1.5 val clipHeight = max(lineHeight * 3, viewportHeightPx.toFloat()) - val safetyMargin = max(2f, lineHeight * 0.12f) - val rawUsableHeight = max(lineHeight * 3, clipHeight - topInsetPx - bottomInsetPx - safetyMargin) - val usableLineCount = max(3, floor(rawUsableHeight / lineHeight).toInt()) - return max(lineHeight * 3, usableLineCount * lineHeight).toInt() + // The outer reader already owns the complete two-line gutter. Keep + // every remaining pixel in the page budget: rounding down to a whole + // number of lines turns the remainder into a variable extra gutter. + return max(lineHeight * 3, clipHeight - topInsetPx - bottomInsetPx).toInt() } /** diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt index 5d1c3120a..a9bcdfa26 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorScheme.kt @@ -10,6 +10,9 @@ import io.leostrange.mrcomic.core.ui.theme.ReadingPreset */ internal object ReaderColorScheme { + /** The only quick schemes exposed by the graphic (PDF/DJVU/raster) reader. */ + val graphicQuickChoices: List = listOf("DAY", "SEPIA", "NIGHT") + /** Background → foreground color pair for a named color scheme. */ fun palette(scheme: String): Pair = when (scheme) { "SEPIA" -> "#f4ecd8" to "#3b2a1a" diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt index 22543f521..35bed2003 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetPersistence.kt @@ -16,6 +16,7 @@ internal suspend fun persistReaderStylePresetSnapshot( readerPreferences.set(PreferencesKeys.READER_PRESET, snapshot.readerPreset) readerPreferences.set(PreferencesKeys.TEXT_FONT_SIZE, snapshot.textFontSize) readerPreferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, snapshot.textColorScheme) + readerPreferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, snapshot.textColorScheme) readerPreferences.set(PreferencesKeys.TEXT_FONT_FAMILY, snapshot.textFontFamily) readerPreferences.set(PreferencesKeys.TEXT_LINE_HEIGHT, snapshot.textLineHeight) readerPreferences.set(PreferencesKeys.TEXT_LETTER_SPACING, snapshot.textLetterSpacing) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt index af5c801da..ec1cd9387 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducer.kt @@ -5,6 +5,7 @@ import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderChromeState import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSnapshot import io.leostrange.mrcomic.feature.reader.ui.ReaderUiState +import io.leostrange.mrcomic.feature.reader.ui.isTextContainer /** * Pure state reducer for reader style presets. @@ -37,8 +38,11 @@ object ReaderStylePresetReducer { /** * Applies a built-in [ReadingPreset] to the state. * - * For non-CUSTOM presets, all typography fields are replaced with the - * preset's values and custom colors are cleared. Chrome is expanded. + * BUG-PAGED-02 / T3: Only colors and chrome-related settings are updated + * when switching presets. Typography (fontSize, lineHeight, letterSpacing, + * wordSpacing, paragraphSpacing, alignment, bold, fontFamily) is preserved + * from the current state so color-only preset changes don't trigger + * re-pagination or shift text. * * For [ReadingPreset.CUSTOM], only the preset name is updated (the user * keeps their current typography values). @@ -54,19 +58,16 @@ object ReaderStylePresetReducer { return state.copy( readerPreset = preset.name, textColorScheme = style.textColorScheme, + graphicColorScheme = style.textColorScheme, textCustomTextColor = null, textCustomBackgroundColor = null, textCustomAccentColor = null, - textFontFamily = style.fontFamily, - textLineHeight = style.lineHeight, - textLetterSpacing = style.letterSpacing, - textWordSpacing = style.wordSpacing, - textParagraphSpacing = style.paragraphSpacing, - textAlignment = style.textAlignment, - textBold = style.textBold, immersiveMode = style.immersiveMode, readerPageAnimation = style.pageAnimation, chromeState = ReaderChromeState.EXPANDED + // Typography fields (textFontFamily, textLineHeight, textLetterSpacing, + // textWordSpacing, textParagraphSpacing, textAlignment, textBold) are + // deliberately preserved from the current state to avoid re-pagination. ) } @@ -91,6 +92,7 @@ object ReaderStylePresetReducer { readerPreset = ReadingPreset.CUSTOM.name, textFontSize = DEFAULT_FONT_SIZE, textColorScheme = DEFAULT_COLOR_SCHEME, + graphicColorScheme = io.leostrange.mrcomic.feature.reader.ui.DEFAULT_GRAPHIC_COLOR_SCHEME, textCustomTextColor = null, textCustomBackgroundColor = null, textCustomAccentColor = null, @@ -120,8 +122,14 @@ object ReaderStylePresetReducer { fun setFontSize(state: ReaderUiState, size: Int): ReaderUiState = state.copy(readerPreset = ReadingPreset.CUSTOM.name, textFontSize = size) - fun setColorScheme(state: ReaderUiState, scheme: String): ReaderUiState = - state.copy(readerPreset = ReadingPreset.CUSTOM.name, textColorScheme = scheme) + fun setColorScheme(state: ReaderUiState, scheme: String): ReaderUiState { + val isText = state.readerContainerKind.isTextContainer() + return state.copy( + readerPreset = ReadingPreset.CUSTOM.name, + textColorScheme = if (isText) scheme else state.textColorScheme, + graphicColorScheme = if (!isText) scheme else state.graphicColorScheme + ) + } fun setFontFamily(state: ReaderUiState, family: String): ReaderUiState = state.copy(readerPreset = ReadingPreset.CUSTOM.name, textFontFamily = family) diff --git a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt index 99ab709bf..561775bdd 100644 --- a/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt +++ b/android/feature-reader/src/main/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetUiStateMapper.kt @@ -33,6 +33,7 @@ internal fun ReaderUiState.applyReaderStylePreset( readerPreset = snapshot.readerPreset, textFontSize = snapshot.textFontSize, textColorScheme = snapshot.textColorScheme, + graphicColorScheme = snapshot.textColorScheme, textCustomTextColor = snapshot.textCustomTextColor, textCustomBackgroundColor = snapshot.textCustomBackgroundColor, textCustomAccentColor = snapshot.textCustomAccentColor, diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetectorTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetectorTest.kt new file mode 100644 index 000000000..57a041927 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/MarginCropAutoDetectorTest.kt @@ -0,0 +1,104 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarginCropAutoDetectorTest { + + /** Solid page: uniform paper with a dark content block at the given insets. */ + private fun grid( + width: Int, + height: Int, + paper: Int = 240, + ink: Int = 30, + insets: ReaderMarginCropSides + ): (Int, Int) -> Int { + val leftPx = (width * insets.left).toInt() + val topPx = (height * insets.top).toInt() + val rightPx = width - (width * insets.right).toInt() + val bottomPx = height - (height * insets.bottom).toInt() + return { x, y -> + if (x in leftPx until rightPx && y in topPx until bottomPx) ink else paper + } + } + + @Test + fun tinyGridsReturnNoCrop() { + assertTrue(MarginCropAutoDetector.detect(8, 8) { _, _ -> 0 }.isZero) + assertTrue(MarginCropAutoDetector.detect(0, 0) { _, _ -> 0 }.isZero) + } + + @Test + fun blankPageIsNotCropped() { + val result = MarginCropAutoDetector.detect(100, 140) { _, _ -> 230 } + assertTrue("Blank page must stay uncropped: $result", result.isZero) + } + + @Test + fun symmetricMarginsAreDetected() { + val width = 200 + val height = 280 + // 10% margins around a text block. + val result = MarginCropAutoDetector.detect( + width, + height, + luminance = grid(width, height, insets = ReaderMarginCropSides(0.10f, 0.10f, 0.10f, 0.10f)) + ) + assertEquals(0.10f, result.left, 0.02f) + assertEquals(0.10f, result.right, 0.02f) + assertEquals(0.10f, result.top, 0.02f) + assertEquals(0.10f, result.bottom, 0.02f) + } + + @Test + fun asymmetricMarginsAreDetectedIndependently() { + val width = 200 + val height = 280 + val result = MarginCropAutoDetector.detect( + width, + height, + luminance = grid( + width, + height, + insets = ReaderMarginCropSides(left = 0.04f, top = 0.12f, right = 0.16f, bottom = 0.02f) + ) + ) + assertEquals(0.04f, result.left, 0.02f) + assertEquals(0.16f, result.right, 0.02f) + assertEquals(0.12f, result.top, 0.02f) + assertEquals(0.02f, result.bottom, 0.02f) + } + + @Test + fun contentBeyondTheCapIsNeverOverCropped() { + val width = 200 + val height = 280 + // Content occupies only the middle third: real insets are ~0.33, but the + // detector must cap at MAX_SIDE_FRACTION and never crop past it. + val result = MarginCropAutoDetector.detect( + width, + height, + luminance = grid(width, height, insets = ReaderMarginCropSides(0.33f, 0.33f, 0.33f, 0.33f)) + ) + assertTrue(result.left <= ReaderMarginCrop.MAX_SIDE_FRACTION) + assertTrue(result.top <= ReaderMarginCrop.MAX_SIDE_FRACTION) + assertTrue(result.left >= 0.20f) + assertTrue(result.top >= 0.20f) + } + + @Test + fun dustSpecksNearTheBorderDoNotBlockCropping() { + val width = 200 + val height = 280 + val base = grid(width, height, insets = ReaderMarginCropSides(0.10f, 0.10f, 0.10f, 0.10f)) + val result = MarginCropAutoDetector.detect(width, height) { x, y -> + // A few isolated dark pixels 2% from the edges (dust/speckle). + val nearLeftSpeck = x == 4 && y == 140 + val nearTopSpeck = y == 5 && x == 100 + if (nearLeftSpeck || nearTopSpeck) 20 else base(x, y) + } + // The left line at x=4 has 1 dark sample out of 280 (<5% share). + assertTrue("Specks must be ignored: $result", result.left >= 0.08f) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCropTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCropTest.kt new file mode 100644 index 000000000..6095637c5 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/crop/ReaderMarginCropTest.kt @@ -0,0 +1,110 @@ +package io.leostrange.mrcomic.feature.reader.domain.crop + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderMarginCropTest { + + @Test + fun clampCapsEachSideAtMaxFraction() { + val crop = ReaderMarginCrop(enabled = true, left = 0.5f, top = -0.4f, right = 0.1f, bottom = 0f) + val clamped = crop.clamped() + assertEquals(ReaderMarginCrop.MAX_SIDE_FRACTION, clamped.left, 1e-6f) + assertEquals(0f, clamped.top, 1e-6f) + assertEquals(0.1f, clamped.right, 1e-6f) + assertEquals(0f, clamped.bottom, 1e-6f) + } + + @Test + fun symmetricEnforceKeepsLargerSideOfEachAxis() { + val crop = ReaderMarginCrop( + enabled = true, + left = 0.10f, + right = 0.04f, + top = 0.02f, + bottom = 0.08f, + symmetric = true + ) + val enforced = crop.withSymmetricEnforced() + assertEquals(0.10f, enforced.left, 1e-6f) + assertEquals(0.10f, enforced.right, 1e-6f) + assertEquals(0.08f, enforced.top, 1e-6f) + assertEquals(0.08f, enforced.bottom, 1e-6f) + } + + @Test + fun symmetricEnforceKeepsAsymmetricValuesWhenDisabled() { + val crop = ReaderMarginCrop(left = 0.10f, right = 0.04f, symmetric = false) + val enforced = crop.withSymmetricEnforced() + assertEquals(0.10f, enforced.left, 1e-6f) + assertEquals(0.04f, enforced.right, 1e-6f) + } + + @Test + fun pairsAveragedProducesEqualAxes() { + val averaged = ReaderMarginCrop(left = 0.10f, right = 0.02f, top = 0f, bottom = 0.06f) + .withPairsAveraged() + assertEquals(0.06f, averaged.left, 1e-6f) + assertEquals(0.06f, averaged.right, 1e-6f) + assertEquals(0.03f, averaged.top, 1e-6f) + assertEquals(0.03f, averaged.bottom, 1e-6f) + } + + @Test + fun setSideMirrorsOppositeSideInSymmetricMode() { + val updated = ReaderMarginCrop(enabled = true, symmetric = true) + .withSide(ReaderMarginCropSide.LEFT, 0.07f) + assertEquals(0.07f, updated.left, 1e-6f) + assertEquals(0.07f, updated.right, 1e-6f) + assertEquals(0f, updated.top, 1e-6f) + } + + @Test + fun setSideKeepsOppositeSideIndependentWhenAsymmetric() { + val updated = ReaderMarginCrop(symmetric = false, right = 0.05f) + .withSide(ReaderMarginCropSide.LEFT, 0.09f) + assertEquals(0.09f, updated.left, 1e-6f) + assertEquals(0.05f, updated.right, 1e-6f) + } + + @Test + fun setSideDoesNotImplicitlyEnableCrop() { + val updated = ReaderMarginCrop(enabled = false, symmetric = false) + .withSide(ReaderMarginCropSide.TOP, 0.10f) + assertFalse(updated.enabled) + assertTrue(updated.hasVisibleCrop) + assertFalse(updated.isActive) + } + + @Test + fun seededFromLegacyFillsAllSides() { + val seeded = ReaderMarginCrop().seededFromLegacy(horizontal = 0.10f, vertical = 0.05f) + assertEquals(0.10f, seeded.left, 1e-6f) + assertEquals(0.10f, seeded.right, 1e-6f) + assertEquals(0.05f, seeded.top, 1e-6f) + assertEquals(0.05f, seeded.bottom, 1e-6f) + } + + @Test + fun effectiveSidesAreZeroWhenUnsupportedOrDisabled() { + val crop = ReaderMarginCrop(enabled = true, left = 0.1f, right = 0.1f, top = 0.05f, bottom = 0.05f) + assertTrue(crop.effectiveSides(supported = false).isZero) + assertTrue(crop.copy(enabled = false).effectiveSides(supported = true).isZero) + val effective = crop.effectiveSides(supported = true) + assertFalse(effective.isZero) + assertEquals(0.1f, effective.left, 1e-6f) + assertEquals(0.05f, effective.top, 1e-6f) + assertEquals(0.1f, effective.right, 1e-6f) + assertEquals(0.05f, effective.bottom, 1e-6f) + } + + @Test + fun sideEnumRoundTripsStoredValues() { + ReaderMarginCropSide.entries.forEach { side -> + assertEquals(side, ReaderMarginCropSide.fromStored(side.storedValue)) + } + assertEquals(null, ReaderMarginCropSide.fromStored("diagonal")) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt index 8b069a919..83eb54d61 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/domain/progress/ReaderPositionCodecTest.kt @@ -139,4 +139,21 @@ class ReaderPositionCodecTest { assertFalse(json.isBlank()) assertTrue(json.contains("\"s\":0")) } + + // ── BUG-READER-02: mode must survive roundtrip for all reading modes ── + + @Test + fun roundTrip_preservesAllReadingModes() { + // Every reading mode must survive a JSON roundtrip so per-book mode restore works. + for (mode in ReadingMode.entries) { + val position = ReaderPosition( + engineSectionIndex = 5, + mode = mode, + schemaVersion = ReaderPosition.SCHEMA_VERSION, + ) + val decoded = ReaderPositionCodec.decode(ReaderPositionCodec.encode(position)) + assertNotNull("Mode $mode must survive roundtrip", decoded) + assertEquals("Mode $mode must be preserved", mode, decoded!!.mode) + } + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt index 6448d0187..ded2098cd 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/EpubProgressCalculatorTest.kt @@ -1,6 +1,7 @@ package io.leostrange.mrcomic.feature.reader.ui import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class EpubProgressCalculatorTest { @@ -71,4 +72,117 @@ class EpubProgressCalculatorTest { assertEquals(18, first.accumulatedCurrentPage) assertEquals(first, second) } + + @Test + fun accumulateKeepsTheSessionEstimateStableAsMoreSectionsAreMeasured() { + val firstMeasurement = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 2), + sectionIndex = 0, + sectionPageIndex = 0, + totalSections = 4, + stableEstimateOverride = 2 + ) + val afterAnotherSection = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 2, 1 to 8), + sectionIndex = 1, + sectionPageIndex = 0, + totalSections = 4, + stableEstimateOverride = 2 + ) + + assertEquals(8, firstMeasurement.accumulatedTotalPages) + assertEquals(8, afterAnotherSection.accumulatedTotalPages) + } + + /** + * T3 regression: when totalSections is provisional (deferred page-count still resolving) + * and is smaller than the section the user is currently in, the accumulated current page + * must not exceed the accumulated total. Before the fix, total could be smaller than + * current (e.g. current=63, total=12) causing the progress to show 100%. + */ + @Test + fun accumulateCoversCurrentSectionWhenTotalSectionsIsProvisional() { + // Simulates: EPUB with 100 sections, but deferred count only knows about 1. + // User is on section 5 with 12 visual pages, on page 3. + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(5 to 12), + sectionIndex = 5, + sectionPageIndex = 3, + totalSections = 1 // provisional — hasn't resolved yet + ) + + // effectiveTotalSections = max(1, 5+1) = 6 + // visitedTotal = 12, stableEstimate = 12 + // total = 12 + 12 * (6 - 1) = 12 + 60 = 72 + // current = 12*5 + 3 = 63 + assertEquals(72, progress.accumulatedTotalPages) + assertEquals(63, progress.accumulatedCurrentPage) + } + + /** + * T3 regression: accumulatedCurrentPage must never exceed accumulatedTotalPages, + * even in edge cases with very small provisional totalSections. + */ + @Test + fun accumulateNeverShowsPageBeyondTotal() { + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 20), + sectionIndex = 0, + sectionPageIndex = 19, + totalSections = 0 + ) + + // effectiveTotalSections = max(0, 0+1) = 1 + // total = 20, current = 19 + assertEquals(20, progress.accumulatedTotalPages) + assertEquals(19, progress.accumulatedCurrentPage) + assertTrue( + "Current page must not exceed total pages", + progress.accumulatedCurrentPage <= progress.accumulatedTotalPages + ) + } + + /** + * T3 regression: when the user is in the last section and totalSections is accurate, + * the progress should reach 100% only at the very last visual page. + */ + @Test + fun accumulateReachesHundredPercentOnlyAtLastPage() { + val lastSectionProgress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 10, 1 to 5), + sectionIndex = 1, + sectionPageIndex = 4, + totalSections = 2 + ) + + // current = 10 + 4 = 14, total = 15 + assertEquals(15, lastSectionProgress.accumulatedTotalPages) + assertEquals(14, lastSectionProgress.accumulatedCurrentPage) + assertTrue(lastSectionProgress.isResolved) + } + + @Test + fun isResolvedReturnsFalseWhenUnvisitedSectionsRemain() { + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = mapOf(0 to 10), + sectionIndex = 0, + sectionPageIndex = 2, + totalSections = 5 + ) + assertEquals(false, progress.isResolved) + assertEquals(false, EpubProgressCalculator.isResolved(mapOf(0 to 10), 5)) + } + + @Test + fun isResolvedReturnsTrueWhenAllSectionsMeasured() { + val map = mapOf(0 to 10, 1 to 12, 2 to 8) + val progress = EpubProgressCalculator.accumulate( + sectionPageCounts = map, + sectionIndex = 1, + sectionPageIndex = 2, + totalSections = 3 + ) + assertEquals(true, progress.isResolved) + assertEquals(true, EpubProgressCalculator.isResolved(map, 3)) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt index e8c16b31f..73c1b8f83 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderAutoScrollDispatchTest.kt @@ -108,39 +108,22 @@ class ReaderAutoScrollDispatchTest { } @Test - fun `hidden chrome reserves a dock only for paged readers`() { - assertEquals( - 72, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.TEXT_PAGE, - chromeHidden = true, - enabled = true, - ), - ) - assertEquals( - 0, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.TEXT_WEBTOON, - chromeHidden = true, - enabled = true, - ), - ) - assertEquals( - 0, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.RASTER_WEBTOON, - chromeHidden = true, - enabled = true, - ), - ) - assertEquals( - 0, - readerAutoScrollDockHeightDp( - containerKind = ReaderContainerKind.RASTER_PAGE, - chromeHidden = true, - enabled = false, - ), - ) + fun `auto scroll dock height is always zero — page turn uses timer, not scroll`() { + ReaderContainerKind.entries.forEach { kind -> + listOf(true, false).forEach { chromeHidden -> + listOf(true, false).forEach { enabled -> + assertEquals( + "kind=$kind chromeHidden=$chromeHidden enabled=$enabled", + 0, + readerAutoScrollDockHeightDp( + containerKind = kind, + chromeHidden = chromeHidden, + enabled = enabled, + ), + ) + } + } + } } @Test diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt index f4a6b34b4..2889caa81 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderBookOpeningControllerTest.kt @@ -309,6 +309,44 @@ class ReaderBookOpeningControllerTest { greenPath ) } - // (close-tests are covered below by the dedicated SessionCoordinatorTest; - // this file focuses on the open-side integration only.) + // ── BUG-READER-03: legacy fallback must route through planReaderPositionRestore ── + + @Test + fun legacyFallback_currentPageBecomesStartPage() = runTest { + // A legacy record has readerPositionJson=null and currentPage=5. + // The opening pipeline must synthesize a ReaderPosition from currentPage and + // route it through planReaderPositionRestore so the structured path is always used. + val comic = comic(currentPage = 5) + createController(fetchResult = comic) + + assertEquals("Legacy currentPage=5 should become startPage=5", 5, uiState.value.currentPage) + assertEquals("c1", uiState.value.comic?.id) + } + + @Test + fun legacyFallback_zeroPageStartsAtZero() = runTest { + // currentPage=0 with no structured position means "start from the beginning". + val comic = comic(currentPage = 0) + createController(fetchResult = comic) + + assertEquals(0, uiState.value.currentPage) + } + + @Test + fun structuredPosition_takesPrecedenceOverCurrentPage() = runTest { + // When a valid readerPositionJson exists, it must be used instead of currentPage. + val structuredPosition = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPosition( + engineSectionIndex = 3, + mode = io.leostrange.mrcomic.core.model.ReadingMode.PAGE_LTR, + ) + val encodedJson = io.leostrange.mrcomic.feature.reader.domain.progress.ReaderPositionCodec.encode(structuredPosition) + val comic = comic(currentPage = 99).copy(readerPositionJson = encodedJson) + createController(fetchResult = comic) + + assertEquals( + "Structured position (section=3) must win over legacy currentPage=99", + 3, + uiState.value.currentPage + ) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt index 449c0bcae..508358558 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderContentPolicyTest.kt @@ -4,10 +4,89 @@ import io.leostrange.mrcomic.core.model.ComicFormat import io.leostrange.mrcomic.core.model.ReadingMode import io.leostrange.mrcomic.core.model.isTextReadingFormat import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class ReaderContentPolicyTest { + @Test + fun graphicVerticalStripSupportsImageMarginCrop() { + assertTrue( + supportsImageMarginCrop(ReaderContainerKind.RASTER_WEBTOON, ComicFormat.CBZ) + ) + } + + @Test + fun textVerticalStripDoesNotExposeImageMarginCrop() { + assertFalse( + supportsImageMarginCrop(ReaderContainerKind.TEXT_WEBTOON, ComicFormat.TXT) + ) + } + + @Test + fun documentPagesKeepPdfAndDjvuMarginCrop() { + assertTrue(supportsImageMarginCrop(ReaderContainerKind.RASTER_PAGE, ComicFormat.PDF)) + assertTrue(supportsImageMarginCrop(ReaderContainerKind.RASTER_PAGE, ComicFormat.DJVU)) + } + + @Test + fun ordinaryRasterPagesDoNotGetDocumentMarginCropControls() { + assertFalse(supportsImageMarginCrop(ReaderContainerKind.RASTER_PAGE, ComicFormat.CBZ)) + } + + @Test + fun cropButtonUnlocksOnlyForDocumentFormats() { + assertTrue(supportsDocumentMarginCropButton(ComicFormat.PDF)) + assertTrue(supportsDocumentMarginCropButton(ComicFormat.DJVU)) + // Comics stay locked even though they are raster too. + assertFalse(supportsDocumentMarginCropButton(ComicFormat.CBZ)) + assertFalse(supportsDocumentMarginCropButton(ComicFormat.CBR)) + assertFalse(supportsDocumentMarginCropButton(null)) + } + + @Test + fun effectiveMarginCropRespectsSupportAndEnableFlags() { + // Disabled → no crop even for PDF. + assertTrue( + resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.RASTER_PAGE, + format = ComicFormat.PDF, + enabled = false, + left = 0.1f, top = 0.1f, right = 0.1f, bottom = 0.1f + ).isZero + ) + // Comics never receive crop even when values are stored. + assertTrue( + resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.RASTER_PAGE, + format = ComicFormat.CBZ, + enabled = true, + left = 0.1f, top = 0.1f, right = 0.1f, bottom = 0.1f + ).isZero + ) + // Enabled PDF gets the per-side values clamped. + val crop = resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.RASTER_PAGE, + format = ComicFormat.PDF, + enabled = true, + left = 0.04f, top = 0.5f, right = 0.16f, bottom = 0.02f + ) + assertEquals(0.04f, crop.normalizedLeft, 1e-6f) + assertEquals(0.22f, crop.normalizedTop, 1e-6f) + assertEquals(0.16f, crop.normalizedRight, 1e-6f) + assertEquals(0.02f, crop.normalizedBottom, 1e-6f) + // Text containers never crop. + assertTrue( + resolveEffectiveImageMarginCrop( + containerKind = ReaderContainerKind.TEXT_PAGE, + format = ComicFormat.EPUB, + enabled = true, + left = 0.1f, top = 0.1f, right = 0.1f, bottom = 0.1f + ).isZero + ) + } + @Test fun textFormatsInPageModesResolveToTextPage() { val textFormats = listOf( diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt index be1f72bd5..5d9ad5e65 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderHtmlCssJsTest.kt @@ -139,6 +139,24 @@ class ReaderHtmlCssJsTest { ) } + @Test + fun textSettingsJs_justifiesOnlyReadingParagraphsAndKeepsTheirLastLineNatural() { + val js = textSettingsJs( + fontSize = 18, + bg = "#fafafa", + fg = "#1a1a1a", + align = "justify", + pagedMode = true + ) + + assertTrue(js.contains("text-align-last:start !important")) + assertTrue(js.contains("hyphens:auto !important")) + assertFalse( + "headings and structural containers must retain publisher alignment", + js.contains("querySelectorAll('p,div,section,article,blockquote,li,td,th,h1,h2,h3,h4,h5,h6')") + ) + } + @Test fun tapHandler_blocksSelectionInPagedMode() { assertTrue(JS_TAP_HANDLER.contains("window.__mrcomicPagedModeScrollLock||hasActivePagedLayout()")) @@ -377,6 +395,34 @@ class ReaderHtmlCssJsTest { ) } + @Test + fun readerPagedLayoutJs_usesSingleUsableHeightForEveryPageBudget() { + val js = readerPagedLayoutJs(targetPage = 0) + + assertTrue( + "page boundaries must use the same height as the reported usable viewport", + js.contains("var pageBudget=Math.max(lineHeight*3,usableHeight);") + ) + assertFalse( + "page boundaries must not use a second independent clip-height formula", + js.contains("clipHeight-pageTopInset-pageBottomInset-bodyPaddingBottom") + ) + } + + @Test + fun readerPagedLayoutJs_doesNotConvertViewportRemainderIntoBottomGutter() { + val js = readerPagedLayoutJs(targetPage = 0) + + assertTrue( + "the complete available viewport must be the page budget", + js.contains("var usableHeight=rawUsableHeight;") + ) + assertFalse( + "flooring by line height creates a variable extra bottom gutter", + js.contains("Math.floor(rawUsableHeight/lineHeight)") + ) + } + @Test fun readerPagedLayoutJs_masksFractionalBoundaryBeforeTheNextLine() { val js = readerPagedLayoutJs(targetPage = 0) @@ -450,7 +496,7 @@ class ReaderHtmlCssJsTest { } @Test - fun readerHtmlPageSourceReloadKeyUsesDocumentIdentityOnly() { + fun readerHtmlPageSourceReloadKeyUsesDocumentIdentityAndTheme() { val key = readerHtmlPageSourceReloadKey( html = "

Hello

", resolvedBaseUrl = "https://appassets.androidplatform.net/content/book.xhtml", diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicyTest.kt new file mode 100644 index 000000000..3398e2203 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMarginCropLayoutPolicyTest.kt @@ -0,0 +1,24 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderMarginCropLayoutPolicyTest { + @Test + fun landscapeUsesTwoColumnsAndKeepsWidePageMargins() { + val layout = readerMarginCropLayout(isLandscape = true) + + assertEquals(2, layout.sideColumns) + assertEquals(0.66f, layout.widthFraction, 0.001f) + assertEquals(8f, layout.verticalPaddingDp, 0.001f) + } + + @Test + fun portraitRemainsSingleColumnButMoreCompact() { + val layout = readerMarginCropLayout(isLandscape = false) + + assertEquals(1, layout.sideColumns) + assertEquals(0.86f, layout.widthFraction, 0.001f) + assertEquals(12f, layout.verticalPaddingDp, 0.001f) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt index bafd5d980..8bdeb120f 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderMaterialColorSchemeTest.kt @@ -77,6 +77,80 @@ class ReaderMaterialColorSchemeTest { assertTrue("Expected ≥ 3:1, got $ratio", ratio >= 3f) } + @Test + fun `raster SEPIA preset changes background to warm tone`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.SEPIA_BOOK, + textColorScheme = "SEPIA", + fallback = darkColorScheme() + ) + // Sepia background should be warm (R > B) + assertTrue("Expected warm background", scheme.background.red > scheme.background.blue) + } + + @Test + fun `raster NIGHT preset changes background to dark`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.NIGHT_INK, + textColorScheme = "NIGHT", + fallback = darkColorScheme() + ) + assertTrue("Expected dark background", scheme.background.luminance() < 0.1f) + val ratio = contrastRatio(scheme.onBackground, scheme.background) + assertTrue("Expected ≥ 3:1, got $ratio", ratio >= 3f) + } + + @Test + fun `raster OLED preset uses pure black background`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.OLED_BLACK, + textColorScheme = "DAY", + fallback = darkColorScheme() + ) + assertTrue("Expected pure black", scheme.background == Color(0xFF000000)) + } + + @Test + fun `raster default preset differs from SEPIA`() { + val default = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.PAPER, + textColorScheme = "DAY", + fallback = darkColorScheme() + ) + val sepia = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.SEPIA_BOOK, + textColorScheme = "SEPIA", + fallback = darkColorScheme() + ) + assertTrue("Backgrounds should differ", default.background != sepia.background) + } + + // ── BUG-UI-02: DAY-scheme presets must have distinct backgrounds in raster reader ── + + @Test + fun `raster PAPER, NEWSPAPER, and EINK have distinct backgrounds`() { + val paper = readerMaterialColorScheme( + isTextReader = false, readerPreset = ReadingPreset.PAPER, + textColorScheme = "DAY", fallback = darkColorScheme() + ) + val newspaper = readerMaterialColorScheme( + isTextReader = false, readerPreset = ReadingPreset.NEWSPAPER, + textColorScheme = "DAY", fallback = darkColorScheme() + ) + val eink = readerMaterialColorScheme( + isTextReader = false, readerPreset = ReadingPreset.EINK, + textColorScheme = "DAY", fallback = darkColorScheme() + ) + assertTrue("PAPER background ≠ NEWSPAPER background", paper.background != newspaper.background) + assertTrue("PAPER background ≠ EINK background", paper.background != eink.background) + assertTrue("NEWSPAPER background ≠ EINK background", newspaper.background != eink.background) + } + @Test fun `EINK preset maintains high contrast`() { val scheme = readerMaterialColorScheme( @@ -90,4 +164,51 @@ class ReaderMaterialColorSchemeTest { assertTrue("Background contrast ≥ 4.5:1, got $bgRatio", bgRatio >= 4.5f) assertTrue("Surface contrast ≥ 4.5:1, got $surfaceRatio", surfaceRatio >= 4.5f) } + + // ── BUG-UI-05: raster light presets must resolve to LIGHT color schemes so + // switches, sliders and progress bars follow the Day/Sepia preset ───────────── + + private fun isLightScheme(scheme: androidx.compose.material3.ColorScheme): Boolean = + scheme.primaryContainer.luminance() > 0.5f && + scheme.secondaryContainer.luminance() > 0.5f && + scheme.surfaceContainerHigh.luminance() > 0.5f + + @Test + fun `raster DAY preset resolves to a light scheme`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.PAPER, + textColorScheme = "DAY", + fallback = darkColorScheme() + ) + assertTrue( + "Expected light companions for raster DAY, primaryContainer lum=${scheme.primaryContainer.luminance()}", + isLightScheme(scheme) + ) + } + + @Test + fun `raster SEPIA preset resolves to a light scheme`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.SEPIA_BOOK, + textColorScheme = "SEPIA", + fallback = darkColorScheme() + ) + assertTrue("Expected light companions for raster SEPIA", isLightScheme(scheme)) + } + + @Test + fun `raster NIGHT preset stays a dark scheme`() { + val scheme = readerMaterialColorScheme( + isTextReader = false, + readerPreset = ReadingPreset.NIGHT_INK, + textColorScheme = "NIGHT", + fallback = darkColorScheme() + ) + assertTrue( + "Expected dark companions for raster NIGHT", + scheme.primaryContainer.luminance() < 0.3f + ) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicyTest.kt new file mode 100644 index 000000000..28ae1f007 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderPageImageScalePolicyTest.kt @@ -0,0 +1,33 @@ +package io.leostrange.mrcomic.feature.reader.ui + +import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.ReaderImageScaleMode +import io.leostrange.mrcomic.core.model.ReadingMode +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderPageImageScalePolicyTest { + @Test + fun documentSpreadAlwaysFitsAvailableHeight() { + assertEquals( + ReaderImageScaleMode.FIT_HEIGHT.storedValue, + resolvePageImageScaleMode( + format = ComicFormat.PDF, + readingMode = ReadingMode.DUAL_PAGE, + requestedMode = ReaderImageScaleMode.FIT_WIDTH.storedValue, + ) + ) + } + + @Test + fun singlePdfPageKeepsRequestedScale() { + assertEquals( + ReaderImageScaleMode.FIT_WIDTH.storedValue, + resolvePageImageScaleMode( + format = ComicFormat.PDF, + readingMode = ReadingMode.PAGE_LTR, + requestedMode = ReaderImageScaleMode.FIT_WIDTH.storedValue, + ) + ) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt index 75004948c..848465e0d 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderReadingTabTest.kt @@ -48,13 +48,14 @@ class ReaderReadingTabTest { private fun setTab( uiState: ReaderUiState = ReaderUiState(), - callbacks: Callbacks = Callbacks() + callbacks: Callbacks = Callbacks(), + isTextReader: Boolean = false ) { composeRule.setContent { CompositionLocalProvider(LocalStrings provides English) { ReaderReadingTab( uiState = uiState, - isTextReader = false, + isTextReader = isTextReader, onReadingModeChange = { callbacks.readingMode = it }, onKeepScreenOnChange = { callbacks.keepScreenOn = it }, onScreenTimeoutChange = {}, @@ -143,7 +144,7 @@ class ReaderReadingTabTest { @Test fun `header and footer sliders render`() { - setTab() + setTab(isTextReader = true) scrollTo("Font size") composeRule.onNodeWithText("Font size").assertIsDisplayed() @@ -155,6 +156,16 @@ class ReaderReadingTabTest { composeRule.onNodeWithText("Right inset").assertIsDisplayed() } + @Test + fun `header and footer typography sliders are hidden for graphic readers`() { + setTab(isTextReader = false) + + composeRule.onAllNodesWithText("Font size").assertCountEquals(0) + composeRule.onAllNodesWithText("Vertical padding").assertCountEquals(0) + composeRule.onAllNodesWithText("Left inset").assertCountEquals(0) + composeRule.onAllNodesWithText("Right inset").assertCountEquals(0) + } + @Test fun `webtoon mode disables page animation chips`() { setTab(uiState = ReaderUiState(readingMode = ReadingMode.WEBTOON)) diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt index 0eafdc833..bd228ef79 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderTextChromeLayoutPolicyTest.kt @@ -5,17 +5,68 @@ import org.junit.Test class ReaderTextChromeLayoutPolicyTest { @Test - fun visibleChrome_doesNotChangeTextLayoutInsets() { + fun twoLineGutterIsTheSingleSymmetricReserve() { + assertEquals(108, readerTextTwoLineGutterPx(54)) + assertEquals(16, readerTextTwoLineGutterPx(1)) + } + + @Test + fun textKeepsSymmetricPersistentGutterWhenChromeIsHidden() { val hidden = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = 0, measuredBottomCssPx = 0, + persistentGutterCssPx = 18, + persistentGutterPx = 54, ) val visible = resolveReaderTextChromeLayoutInsets( measuredTopCssPx = 196, measuredBottomCssPx = 236, + persistentGutterCssPx = 18, + persistentGutterPx = 54, + ) + + assertEquals(ReaderTextChromeLayoutInsets(0, 0, 54, 54), hidden) + assertEquals(ReaderTextChromeLayoutInsets(0, 0, 54, 54), visible) + } + + @Test + fun chromeVisibilityCannotChangeOuterReaderGutter() { + val hidden = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 0, + measuredBottomCssPx = 0, + persistentGutterCssPx = 30, + persistentGutterPx = 90, + ) + val expanded = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 260, + measuredBottomCssPx = 320, + persistentGutterCssPx = 30, + persistentGutterPx = 90, + ) + + assertEquals(hidden.outerTopPx, expanded.outerTopPx) + assertEquals(hidden.outerBottomPx, expanded.outerBottomPx) + assertEquals(hidden.topCssPx, expanded.topCssPx) + assertEquals(hidden.bottomCssPx, expanded.bottomCssPx) + } + + @Test + fun twoStepGutterRemainsSymmetricAndIndependentOfChromeMeasurements() { + val hidden = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 0, + measuredBottomCssPx = 0, + persistentGutterCssPx = 36, + persistentGutterPx = 108, + ) + val visible = resolveReaderTextChromeLayoutInsets( + measuredTopCssPx = 220, + measuredBottomCssPx = 280, + persistentGutterCssPx = 36, + persistentGutterPx = 108, ) - assertEquals(ReaderTextChromeLayoutInsets(0, 0), hidden) + assertEquals(108, hidden.outerTopPx) + assertEquals(108, hidden.outerBottomPx) assertEquals(hidden, visible) } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt index 2c7670e8e..fdf97a524 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/ReaderWebViewRuntimeControllerTest.kt @@ -53,6 +53,28 @@ class ReaderWebViewRuntimeControllerTest { assertTrue(controller.dispatch(ReaderWebViewRuntimeEvent.RestoreAcknowledged(2L)).isEmpty()) } + @Test + fun rejectedRestoreRetriesBeforePublishingReady() { + val controller = ReaderWebViewLoadController() + controller.dispatch(ReaderWebViewRuntimeEvent.LoadRequested("book/page", 12L, target)) + controller.dispatch(ReaderWebViewRuntimeEvent.DocumentCommitted(12L)) + controller.dispatch(ReaderWebViewRuntimeEvent.LayoutReady(12L, metrics)) + + repeat(4) { rejectedAttempt -> + assertEquals( + listOf(ReaderWebViewRuntimeEffect.Restore(12L, target, rejectedAttempt + 2)), + controller.dispatch(ReaderWebViewRuntimeEvent.RestoreRejected(12L)) + ) + assertEquals(ReaderWebViewRuntimePhase.RESTORING, controller.runtimeState.phase) + } + assertEquals( + listOf(ReaderWebViewRuntimeEffect.PublishReady(12L, metrics)), + controller.dispatch(ReaderWebViewRuntimeEvent.RestoreRejected(12L)) + ) + assertEquals(ReaderWebViewRuntimePhase.READY, controller.runtimeState.phase) + assertEquals(target, controller.runtimeState.restoreTarget) + } + @Test fun staleGenerationEventsNeverMutateTheActiveLoad() { val controller = ReaderWebViewLoadController() diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt index 296b71c9c..7baaf0902 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderBottomProgressPolicyTest.kt @@ -19,4 +19,84 @@ class ReaderBottomProgressPolicyTest { assertEquals(14, progress.currentPage) assertEquals(352, progress.totalPages) } + + /** + * T3 regression: when accumulated pages are available, they must always be preferred + * even when sectionPageCount is also present. + */ + @Test + fun textBook_accumulatedPagesAlwaysWinOverSectionFallback() { + val progress = resolveReaderBottomProgress( + currentPage = 10, + totalPages = 50, + isTextBook = true, + sectionPageCount = 8, + epubAccumulatedCurrentPage = 200, + epubAccumulatedTotalPages = 800, + ) + + assertEquals(200, progress.currentPage) + assertEquals(800, progress.totalPages) + } + + /** + * T3 regression: non-text books should use raw page values regardless of accumulated data. + */ + @Test + fun nonTextBook_ignoresAccumulatedEpubData() { + val progress = resolveReaderBottomProgress( + currentPage = 5, + totalPages = 20, + isTextBook = false, + sectionPageCount = 0, + epubAccumulatedCurrentPage = 999, + epubAccumulatedTotalPages = 999, + ) + + assertEquals(5, progress.currentPage) + assertEquals(20, progress.totalPages) + } + + /** + * T3 regression: text book without accumulated data falls back to raw values. + */ + @Test + fun textBook_fallbackToRawValuesWhenNoAccumulatedData() { + val progress = resolveReaderBottomProgress( + currentPage = 3, + totalPages = 15, + isTextBook = true, + sectionPageCount = 10, + epubAccumulatedCurrentPage = 0, + epubAccumulatedTotalPages = 0, + ) + + assertEquals(3, progress.currentPage) + assertEquals(15, progress.totalPages) + } + + @Test + fun textBook_preservesIsResolvedFlag() { + val unresolved = resolveReaderBottomProgress( + currentPage = 5, + totalPages = 91, + isTextBook = true, + sectionPageCount = 8, + epubAccumulatedCurrentPage = 14, + epubAccumulatedTotalPages = 352, + isTextPaginationResolved = false, + ) + assertEquals(false, unresolved.isResolved) + + val resolved = resolveReaderBottomProgress( + currentPage = 5, + totalPages = 91, + isTextBook = true, + sectionPageCount = 8, + epubAccumulatedCurrentPage = 14, + epubAccumulatedTotalPages = 352, + isTextPaginationResolved = true, + ) + assertEquals(true, resolved.isResolved) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffsetTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffsetTest.kt new file mode 100644 index 000000000..1e0630636 --- /dev/null +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/components/ReaderGraphicPageOffsetTest.kt @@ -0,0 +1,25 @@ +package io.leostrange.mrcomic.feature.reader.ui.components + +import io.leostrange.mrcomic.core.model.ComicFormat +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReaderGraphicPageOffsetTest { + + @Test + fun pdfAndDjvuUseTheSameDocumentAlignment() { + val expected = ReaderGraphicPageOffset(xDp = 0, yDp = 20) + + assertEquals(expected, readerGraphicPageOffset(ComicFormat.PDF)) + assertEquals(expected, readerGraphicPageOffset(ComicFormat.DJVU)) + } + + @Test + fun otherGraphicFormatsRemainCentered() { + val expected = ReaderGraphicPageOffset(xDp = 0, yDp = 0) + + assertEquals(expected, readerGraphicPageOffset(ComicFormat.CBZ)) + assertEquals(expected, readerGraphicPageOffset(ComicFormat.CBR)) + assertEquals(expected, readerGraphicPageOffset(null)) + } +} diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt index 39906f4f0..b8ba7d26a 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/geometry/ReaderViewportGeometryTest.kt @@ -44,11 +44,12 @@ class ReaderViewportGeometryTest { densityScale = 2.75f ) - // VERTICAL-01/02: hidden toolbars add 8px safety margin - // top: (84 + 0 + 0 + 8) / 2.75 ≈ 33 CSS px - assertEquals(33, geo.contentTopInsetCssPx) - // bottom: (126 + 0 + 0 + 8) / 2.75 ≈ 49 CSS px - assertEquals(49, geo.contentBottomInsetCssPx) + // BUG-PAGED-02 / T1: System insets are NOT included in CSS insets — + // they are already handled by Compose WindowInsetsPadding modifier. + // top: round((4 safety) / 2.75) = 1 → coerced up to MIN_INSET_CSS_PX = 2 + assertEquals(2, geo.contentTopInsetCssPx) + // bottom: same symmetric calculation → 2 + assertEquals(2, geo.contentBottomInsetCssPx) } // ── Toolbars visible ──────────────────────────────────────────────── @@ -85,10 +86,11 @@ class ReaderViewportGeometryTest { densityScale = 2.75f ) - // 252 / 2.75 ≈ 92 CSS px - assertEquals(92, geo.contentTopInsetCssPx) - // 318 / 2.75 ≈ 116 CSS px - assertEquals(116, geo.contentBottomInsetCssPx) + // BUG-PAGED-02 / T1: System insets are NOT included in CSS insets. + // top: (168 chrome + 0 reader padding + 0 safety) / 2.75 ≈ 61 CSS px + assertEquals(61, geo.contentTopInsetCssPx) + // bottom: (192 chrome + 0 reader padding + 0 safety) / 2.75 ≈ 70 CSS px + assertEquals(70, geo.contentBottomInsetCssPx) } // ── Display cutout ────────────────────────────────────────────────── @@ -193,8 +195,9 @@ class ReaderViewportGeometryTest { ) // Should not crash, densityScale clamped to 1 - // VERTICAL-01: safety margin 8px added when toolbars hidden - assertEquals(92, geo.contentTopInsetCssPx) + // BUG-PAGED-02 / T1: System insets are NOT included in CSS insets. + // top: (0 chrome + 0 reader padding + 4 safety) / 1 = 4 CSS px + assertEquals(4, geo.contentTopInsetCssPx) } // ── Chrome-reserve-only CSS insets (LAYOUT-02) ───────────────────── diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt index a7fb5d1f4..2619a6b6e 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedGesturePolicyTest.kt @@ -302,4 +302,189 @@ class PagedGesturePolicyTest { ) ) } + + // ── BUG-T4 regression: footnote link at screen edge ──────────────────── + + /** + * When the user taps a footnote link at the left edge (xPercent=0.05), + * the gesture policy must return PASS_THROUGH so the WebView can handle + * the link click, not turn the page. + */ + @Test + fun classifyPagedGesture_tapOnFootnoteAtLeftEdge_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.05f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = true + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + /** + * When the user taps a footnote link at the right edge (xPercent=0.95), + * the gesture policy must return PASS_THROUGH so the WebView can handle + * the link click, not turn the page. + */ + @Test + fun classifyPagedGesture_tapOnFootnoteAtRightEdge_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.95f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = true + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + /** + * When a tap at the left edge is NOT on a link, it should still be + * classified as TAP_LEFT (page turn) — this is the normal edge-tap + * behavior that must not be broken. + */ + @Test + fun classifyPagedGesture_edgeTapNotOnLink_tapLeft() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.05f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_LEFT, result) + } + + /** + * Edge tap on a link in the center zone should still pass through + * (the link click should be handled by WebView). + */ + @Test + fun classifyPagedGesture_linkInCenter_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 2f, dy = 1f, + elapsed = 150L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = true + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + // ── BUG-T5 regression: accidental selection during swipe ─────────────── + + /** + * When the finger has moved and there's no active selection, selection + * should always be suppressed — even when the user has held for >500ms + * before moving. + */ + @Test + fun shouldSuppressSelectionOnMove_movedEvenWithLongHold_returnsTrue() { + assertTrue( + PagedGesturePolicy.shouldSuppressSelectionOnMove( + hasMoved = true, + hasActiveSelection = false + ) + ) + } + + /** + * A slow vertical swipe (small dx, moderate dy) should be classified + * as RESOLVED (consumed as page gesture), preventing any selection. + */ + @Test + fun classifyPagedGesture_slowVerticalSwipe_resolved() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 3f, dy = 35f, + elapsed = 600L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = true, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.RESOLVED, result) + } + + /** + * When the user is dragging a selection handle (hasActiveSelection=true), + * even a large vertical swipe should pass through so the WebView handles + * the selection extension. + */ + @Test + fun classifyPagedGesture_verticalSwipeWithActiveSelection_passThrough() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 3f, dy = 50f, + elapsed = 400L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = true, + hasActiveSelection = true, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.PASS_THROUGH, result) + } + + /** + * A horizontal swipe with small vertical component should still be + * classified as a page turn (TAP_LEFT), not pass through. + */ + @Test + fun classifyPagedGesture_horizontalSwipeWithSmallVertical_tapLeft() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = -70f, dy = 15f, + elapsed = 400L, + xPercent = 0.5f, + isEdgeTap = false, + hasMoved = true, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_LEFT, result) + } + + /** + * A tap near the left edge that hasn't moved and isn't on a link should + * be TAP_LEFT (normal page-turn edge tap). + */ + @Test + fun classifyPagedGesture_nearLeftEdgeTap_tapLeft() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 1f, dy = 1f, + elapsed = 100L, + xPercent = 0.10f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_LEFT, result) + } + + /** + * A tap near the right edge that hasn't moved and isn't on a link should + * be TAP_RIGHT (normal page-turn edge tap). + */ + @Test + fun classifyPagedGesture_nearRightEdgeTap_tapRight() { + val result = PagedGesturePolicy.classifyPagedGesture( + dx = 1f, dy = 1f, + elapsed = 100L, + xPercent = 0.90f, + isEdgeTap = true, + hasMoved = false, + hasActiveSelection = false, + touchStartedOnLink = false + ) + assertEquals(PagedGestureAction.TAP_RIGHT, result) + } } diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt index 0971dd5b3..e5ca3b8f0 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/PagedLayoutParamsTest.kt @@ -15,7 +15,7 @@ class PagedLayoutParamsTest { // ── Usable page height ───────────────────────────────────────────────── @Test - fun calculateUsablePageHeight_typicalPhone_returnsLineAligned() { + fun calculateUsablePageHeight_typicalPhone_returnsCompleteBudget() { // 800px viewport, 24px top, 48px bottom, 27px line height val result = PagedLayoutParams.calculateUsablePageHeight( viewportHeightPx = 800, @@ -24,10 +24,8 @@ class PagedLayoutParamsTest { lineHeightPx = 27f ) // clipHeight = max(81, 800) = 800 - // rawUsable = max(81, 800 - 24 - 48 - max(2, 3.24)) = max(81, 724.76) = 724.76 - // usableLineCount = max(3, floor(724.76/27)) = max(3, 26) = 26 - // usableHeight = max(81, 26*27) = max(81, 702) = 702 - assertEquals(702, result) + // rawUsable = max(81, 800 - 24 - 48) = 728 + assertEquals(728, result) } @Test @@ -40,10 +38,8 @@ class PagedLayoutParamsTest { lineHeightPx = 27f ) // clipHeight = max(81, 200) = 200 - // rawUsable = max(81, 200 - 24 - 48 - 3.24) = max(81, 124.76) = 124.76 - // usableLineCount = max(3, floor(124.76/27)) = max(3, 4) = 4 - // usableHeight = max(81, 4*27) = max(81, 108) = 108 - assertEquals(108, result) + // rawUsable = max(81, 200 - 24 - 48) = 128 + assertEquals(128, result) } @Test @@ -55,10 +51,8 @@ class PagedLayoutParamsTest { lineHeightPx = 24f ) // clipHeight = max(72, 900) = 900 - // rawUsable = max(72, 900 - 0 - 0 - max(2, 2.88)) = max(72, 897.12) = 897.12 - // usableLineCount = max(3, floor(897.12/24)) = max(3, 37) = 37 - // usableHeight = max(72, 37*24) = max(72, 888) = 888 - assertEquals(888, result) + // rawUsable = max(72, 900 - 0 - 0) = 900 + assertEquals(900, result) } @Test @@ -71,20 +65,48 @@ class PagedLayoutParamsTest { lineHeightPx = 0f ) // Same as typical phone test with lineHeight=27 - assertEquals(702, result) + assertEquals(728, result) } @Test - fun calculateUsablePageHeight_resultIsMultipleOfLineHeight() { - // Result should always be a multiple of lineHeight (line-aligned) - val lineHeight = 30f + fun calculateUsablePageHeight_preservesNonLineAlignedViewportRemainder() { + val result = PagedLayoutParams.calculateUsablePageHeight( + viewportHeightPx = 997, + topInsetPx = 41, + bottomInsetPx = 42, + lineHeightPx = 30f, + ) + assertEquals(914, result) + } + + @Test + fun calculateUsablePageHeight_doesNotDropAnExtraLineForSafetyMargin() { val result = PagedLayoutParams.calculateUsablePageHeight( viewportHeightPx = 1000, - topInsetPx = 50, - bottomInsetPx = 50, - lineHeightPx = lineHeight + topInsetPx = 40, + bottomInsetPx = 40, + lineHeightPx = 40f ) - assertEquals(0, result % lineHeight.toInt()) + + // The two 40px insets are the complete top/bottom gutter contract. + // A second safety subtraction would floor 850/40 to 21 lines and + // visibly create a third line of empty space at the bottom. + assertEquals(920, result) + } + + @Test + fun calculateUsablePageHeight_keepsFractionalLineRemainderInsidePageBudget() { + val result = PagedLayoutParams.calculateUsablePageHeight( + viewportHeightPx = 800, + topInsetPx = 24, + bottomInsetPx = 48, + lineHeightPx = 27f, + ) + + // The complete viewport budget is 728px. Flooring it to 26 lines + // returns 702px and turns the remaining 26px into a visible third + // bottom gutter even though the outer container already owns two. + assertEquals(728, result) } @Test diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt index a55f53e7a..07d41ba4b 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/gesture/ReaderColorSchemeTest.kt @@ -10,6 +10,11 @@ import org.junit.Test */ class ReaderColorSchemeTest { + @Test + fun graphicQuickChoices_exposesOnlyDaySepiaAndNight() { + assertEquals(listOf("DAY", "SEPIA", "NIGHT"), ReaderColorScheme.graphicQuickChoices) + } + @Test fun palette_dayReturnsLightColors() { val (bg, fg) = ReaderColorScheme.palette("DAY") diff --git a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt index 112594487..388c6faa6 100644 --- a/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt +++ b/android/feature-reader/src/test/java/io/leostrange/mrcomic/feature/reader/ui/preset/ReaderStylePresetReducerTest.kt @@ -4,6 +4,7 @@ import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.core.ui.theme.style import io.leostrange.mrcomic.feature.reader.domain.enums.ReaderChromeState import io.leostrange.mrcomic.feature.reader.domain.preset.ReaderStylePresetSnapshot +import io.leostrange.mrcomic.feature.reader.ui.ReaderContainerKind import io.leostrange.mrcomic.feature.reader.ui.ReaderUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -20,7 +21,7 @@ class ReaderStylePresetReducerTest { // ── Apply built-in preset ────────────────────────────────────────────── @Test - fun applyBuiltInPreset_setsAllTypographyFields() { + fun applyBuiltInPreset_setsColorSchemeAndClearsCustomColors() { val before = ReaderUiState( readerPreset = ReadingPreset.CUSTOM.name, textFontSize = 30, @@ -44,14 +45,17 @@ class ReaderStylePresetReducerTest { assertEquals(ReadingPreset.PAPER.name, after.readerPreset) val style = ReadingPreset.PAPER.style() + // Color scheme is updated to preset (both text and graphic for global presets) assertEquals(style.textColorScheme, after.textColorScheme) - assertEquals(style.fontFamily, after.textFontFamily) - assertEquals(style.lineHeight, after.textLineHeight, 0.001f) - assertEquals(style.letterSpacing, after.textLetterSpacing, 0.001f) - assertEquals(style.wordSpacing, after.textWordSpacing, 0.001f) - assertEquals(style.paragraphSpacing, after.textParagraphSpacing, 0.001f) - assertEquals(style.textAlignment, after.textAlignment) - assertEquals(style.textBold, after.textBold) + assertEquals(style.textColorScheme, after.graphicColorScheme) + // Typography is PRESERVED from the current state (BUG-PAGED-02 / T3) + assertEquals(before.textFontFamily, after.textFontFamily) + assertEquals(before.textLineHeight, after.textLineHeight, 0.001f) + assertEquals(before.textLetterSpacing, after.textLetterSpacing, 0.001f) + assertEquals(before.textWordSpacing, after.textWordSpacing, 0.001f) + assertEquals(before.textParagraphSpacing, after.textParagraphSpacing, 0.001f) + assertEquals(before.textAlignment, after.textAlignment) + assertEquals(before.textBold, after.textBold) assertEquals(style.immersiveMode, after.immersiveMode) assertEquals(style.pageAnimation, after.readerPageAnimation) // Built-in presets clear custom colors @@ -146,6 +150,7 @@ class ReaderStylePresetReducerTest { assertEquals(ReadingPreset.CUSTOM.name, after.readerPreset) assertEquals(ReaderStylePresetReducer.DEFAULT_FONT_SIZE, after.textFontSize) assertEquals(ReaderStylePresetReducer.DEFAULT_COLOR_SCHEME, after.textColorScheme) + assertEquals(io.leostrange.mrcomic.feature.reader.ui.DEFAULT_GRAPHIC_COLOR_SCHEME, after.graphicColorScheme) assertNull(after.textCustomTextColor) assertNull(after.textCustomBackgroundColor) assertNull(after.textCustomAccentColor) @@ -195,6 +200,7 @@ class ReaderStylePresetReducerTest { assertEquals(ReadingPreset.OLED_BLACK.name, after.readerPreset) assertEquals(20, after.textFontSize) assertEquals("NIGHT", after.textColorScheme) + assertEquals("NIGHT", after.graphicColorScheme) assertEquals("Roboto", after.textFontFamily) assertEquals(1.6f, after.textLineHeight, 0.001f) assertEquals(0.02f, after.textLetterSpacing, 0.001f) @@ -228,12 +234,31 @@ class ReaderStylePresetReducerTest { @Test fun setColorScheme_marksCustomAndUpdatesValue() { - val before = ReaderUiState(readerPreset = ReadingPreset.PAPER.name, textColorScheme = "DAY") - - val after = ReaderStylePresetReducer.setColorScheme(before, "SEPIA") + // Text reader: setTextScheme updates textColorScheme + val textState = ReaderUiState( + readerPreset = ReadingPreset.PAPER.name, + textColorScheme = "DAY", + readerContainerKind = ReaderContainerKind.TEXT_PAGE + ) + val textAfter = ReaderStylePresetReducer.setColorScheme(textState, "SEPIA") + assertEquals(ReadingPreset.CUSTOM.name, textAfter.readerPreset) + assertEquals("SEPIA", textAfter.textColorScheme) + } - assertEquals(ReadingPreset.CUSTOM.name, after.readerPreset) - assertEquals("SEPIA", after.textColorScheme) + @Test + fun setColorScheme_graphicReaderUpdatesGraphicColorScheme() { + // Graphic (raster) reader: setColorScheme updates graphicColorScheme, not textColorScheme + val graphicState = ReaderUiState( + readerPreset = ReadingPreset.PAPER.name, + textColorScheme = "DAY", + graphicColorScheme = "NIGHT", + readerContainerKind = ReaderContainerKind.RASTER_PAGE + ) + val graphicAfter = ReaderStylePresetReducer.setColorScheme(graphicState, "SEPIA") + assertEquals(ReadingPreset.CUSTOM.name, graphicAfter.readerPreset) + // textColorScheme stays "DAY" — only graphicColorScheme changes + assertEquals("DAY", graphicAfter.textColorScheme) + assertEquals("SEPIA", graphicAfter.graphicColorScheme) } @Test diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/GamificationViewModel.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/GamificationViewModel.kt new file mode 100644 index 000000000..4ab0d0b7b --- /dev/null +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/GamificationViewModel.kt @@ -0,0 +1,149 @@ +package io.leostrange.mrcomic.feature.settings.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.leostrange.mrcomic.core.domain.analytics.AchievementTracker +import io.leostrange.mrcomic.core.domain.analytics.GamificationIntegration +import io.leostrange.mrcomic.core.domain.analytics.MascotProgressState +import io.leostrange.mrcomic.core.interfaces.analytics.DailyReadingGoalState +import io.leostrange.mrcomic.core.model.AchievementNotification +import io.leostrange.mrcomic.core.model.UserAchievements +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Состояние экрана геймификации + */ +data class GamificationUiState( + val userAchievements: UserAchievements? = null, + val mascotProgress: MascotProgressState? = null, + val goalState: DailyReadingGoalState? = null, + val notifications: List = emptyList(), + val isLoading: Boolean = true, + val error: String? = null +) + +/** + * ViewModel для экранов геймификации + */ +@HiltViewModel +class GamificationViewModel @Inject constructor( + private val gamificationIntegration: GamificationIntegration, + private val achievementTracker: AchievementTracker +) : ViewModel() { + + private val _uiState = MutableStateFlow(GamificationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + observeNotifications() + } + + /** + * Загрузить данные + */ + private fun loadData() { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + + // Подписываемся на изменения достижений + combine( + gamificationIntegration.getUserAchievements(), + achievementTracker.userProgress + ) { achievements, progress -> + GamificationUiState( + userAchievements = achievements, + mascotProgress = MascotProgressState( + approxPagesRead = progress.pagesRead, + completedTitles = progress.titlesCompleted, + xp = progress.totalXp, + stage = progress.mascotStage + ), + goalState = DailyReadingGoalState( + pagesReadToday = progress.pagesRead, + currentStreak = progress.streakDays + ), + isLoading = false + ) + }.collect { state -> + _uiState.value = state + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message + ) + } + } + } + + /** + * Наблюдать за уведомлениями + */ + private fun observeNotifications() { + viewModelScope.launch { + gamificationIntegration.getAchievementNotifications().collect { notifications -> + _uiState.value = _uiState.value.copy(notifications = notifications) + } + } + } + + /** + * Очистить уведомление + */ + fun dismissNotification(notification: AchievementNotification) { + gamificationIntegration.clearNotifications() + } + + /** + * Обновить прогресс чтения + */ + fun updateReadingProgress(pagesRead: Int, sessionPages: Int = 0) { + achievementTracker.updatePagesRead(pagesRead) + if (sessionPages > 0) { + gamificationIntegration.recordSingleSessionReading(sessionPages) + } + } + + /** + * Обновить время чтения + */ + fun updateReadingTime(durationMillis: Long) { + gamificationIntegration.recordReadingTime(durationMillis) + } + + /** + * Обновить завершённые тайтлы + */ + fun updateCompletedTitles(count: Int) { + achievementTracker.updateTitlesCompleted(count) + } + + /** + * Обновить серию дней + */ + fun updateStreak(days: Int) { + achievementTracker.updateStreakDays(days) + } + + /** + * Обновить стадию маскота + */ + fun updateMascotStage(stage: io.leostrange.mrcomic.core.model.MascotStage) { + achievementTracker.updateMascotStage(stage) + } + + /** + * Обновить общий XP + */ + fun updateTotalXp(xp: Int) { + achievementTracker.updateTotalXp(xp) + } +} diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt index 3d57a4eb6..a79588497 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/PerformanceDetailSection.kt @@ -43,10 +43,11 @@ import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.data.preferences.PerfProfile import io.leostrange.mrcomic.core.data.preferences.PerfRenderQuality import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip -import io.leostrange.mrcomic.core.ui.designsystem.MrComicPanelCard -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSliderTile +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItemTrailing +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSectionHeader +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSlider import io.leostrange.mrcomic.core.ui.designsystem.MrComicSurfaceCard -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSwitchRow import kotlinx.coroutines.delay import java.util.Locale @@ -433,8 +434,22 @@ private fun PerfCard( hint: String? = null, content: @Composable ColumnScope.() -> Unit ) { - MrComicPanelCard(title = title, hint = hint) { - content() + // Editorial Ink: flat section header + body. No outer card frame. + Column(modifier = Modifier.fillMaxWidth()) { + MrComicSectionHeader(title = title) + if (!hint.isNullOrBlank()) { + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(0.dp), + content = content, + ) } } @@ -538,15 +553,23 @@ private fun PerfSliderRow( steps: Int, onValueChange: (Float) -> Unit ) { - MrComicSliderTile( - title = title, - valueLabel = subtitle, - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps, - modifier = Modifier.padding(vertical = 10.dp) - ) + Column(modifier = Modifier.fillMaxWidth()) { + MrComicListItem( + title = title, + trailing = MrComicListItemTrailing.Value(subtitle), + onClick = null, + divider = false, + ) + MrComicSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + ) + } } @Composable @@ -590,11 +613,13 @@ private fun PerfSwitchRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit ) { - MrComicSwitchRow( + MrComicListItem( title = title, subtitle = subtitle, - checked = checked, - onCheckedChange = onCheckedChange + trailing = MrComicListItemTrailing.Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ), ) } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt index 94f541753..88ce76380 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAboutSection.kt @@ -7,6 +7,7 @@ package io.leostrange.mrcomic.feature.settings.ui +import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -23,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface @@ -40,6 +42,7 @@ import io.leostrange.mrcomic.core.ui.theme.style internal data class AboutSectionText( val title: String, val description: String, + val versionTitle: String, val overviewTitle: String, val overviewBody: String, val featuresTitle: String, @@ -60,13 +63,14 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "en" -> AboutSectionText( title = "About the app", description = "What the app does, what it is built with, and how to contact the developer.", + versionTitle = "Installed version", overviewTitle = "Program description", overviewBody = "Mr.Comic is an Android reader for books and comics from a local library. It combines file management, reading modes for graphics and text, OCR and dictionary tools, reading progress, and backup features in one app.", featuresTitle = "Key features", features = listOf( "Local library with files, folders, bookmarks, quotes, and the Mr.Comic tab.", "Reader modes for page reading, webtoon scrolling, and text formats with saved progress.", - "OCR, offline dictionaries, translation, and text explanation tools.", + "Developing OCR, offline dictionary, translation, and text explanation tools.", "Theme customization, progress export/import, and library access recovery." ), librariesTitle = "Main libraries", @@ -92,6 +96,7 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "ja" -> AboutSectionText( title = "アプリについて", description = "アプリの役割、使用している技術、開発者への連絡先をまとめています。", + versionTitle = "インストール済みバージョン", overviewTitle = "プログラム概要", overviewBody = "Mr.Comic は、ローカルライブラリの本とコミックを読むための Android リーダーです。ファイル管理、画像とテキストの読書モード、OCR と辞書、読書進捗、バックアップをひとつにまとめています。", featuresTitle = "主な機能", @@ -124,6 +129,7 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "zh" -> AboutSectionText( title = "关于应用", description = "这里汇总应用用途、技术栈以及开发者联系方式。", + versionTitle = "已安装版本", overviewTitle = "程序说明", overviewBody = "Mr.Comic 是一款用于阅读本地书库中图书和漫画的 Android 阅读器。它把文件管理、图像与文本阅读模式、OCR 与词典工具、阅读进度和备份功能集中在一个应用里。", featuresTitle = "主要功能", @@ -156,6 +162,7 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua "ko" -> AboutSectionText( title = "앱 정보", description = "앱의 역할, 사용한 기술, 개발자 연락처를 한곳에 모았습니다.", + versionTitle = "설치된 버전", overviewTitle = "프로그램 설명", overviewBody = "Mr.Comic 은 로컬 라이브러리의 책과 코믹을 읽기 위한 Android 리더입니다. 파일 관리, 그래픽/텍스트 읽기 모드, OCR과 사전 도구, 읽기 진행도와 백업 기능을 하나의 앱으로 묶었습니다.", featuresTitle = "주요 기능", @@ -188,13 +195,14 @@ internal fun aboutSectionText(language: String): AboutSectionText = when (langua else -> AboutSectionText( title = "О приложении", description = "Здесь собраны назначение приложения, стек, лицензии и контакты разработчика.", + versionTitle = "Установленная версия", overviewTitle = "Описание программы", overviewBody = "Mr.Comic — Android-приложение для чтения книг и комиксов из локальной библиотеки. Оно объединяет управление файлами, режимы чтения для графики и текста, OCR и словарные инструменты, прогресс чтения и резервное копирование.", featuresTitle = "Основные функции", features = listOf( "Локальная библиотека: файлы, папки, закладки, цитаты и вкладка Mr.Comic.", "Ридер для постраничного чтения, webtoon-режима и текстовых форматов с сохранением прогресса.", - "OCR, офлайн-словари, перевод и объяснение выделенного текста.", + "Развивающиеся инструменты OCR, офлайн-словарей, перевода и объяснения текста.", "Темы и кастомизация, экспорт/импорт прогресса и восстановление доступа к библиотеке." ), librariesTitle = "Основные библиотеки", @@ -235,6 +243,17 @@ internal fun AboutSection( modifier: Modifier = Modifier ) { val sectionText = remember(strings.languageCode) { aboutSectionText(strings.languageCode) } + val context = LocalContext.current + val packageInfo = remember(context.packageName) { + context.packageManager.getPackageInfo(context.packageName, 0) + } + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + val installedVersion = "${packageInfo.versionName.orEmpty()} ($versionCode)" val contacts = remember { listOf( "xmetalcore@outlook.com", @@ -260,6 +279,15 @@ internal fun AboutSection( ) ) } + item { + SettingsCard(title = sectionText.versionTitle) { + Text( + text = installedVersion, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } item { SettingsCard(title = sectionText.overviewTitle) { Text( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt index 16c09f8ad..fc90fac93 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsAppearanceTheme.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop import io.leostrange.mrcomic.core.ui.theme.ThemeMode import io.leostrange.mrcomic.core.ui.theme.argbLongToThemeColor import io.leostrange.mrcomic.core.ui.theme.previewColors @@ -117,6 +118,7 @@ internal fun ThemePreviewCard( } val previewCardShape = RoundedCornerShape(20.dp) + MrComicPreviewBackdrop(shape = previewCardShape) { MrComicCardSurface( modifier = Modifier .fillMaxWidth() @@ -241,6 +243,7 @@ internal fun ThemePreviewCard( } } } + } } // ──────────── Theme preset card ──────────── diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt index 6b578eaea..10b9a2f34 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsBackupController.kt @@ -29,6 +29,7 @@ import io.leostrange.mrcomic.core.data.repository.ComicRepository import io.leostrange.mrcomic.core.data.repository.QuoteRepository import io.leostrange.mrcomic.core.model.Comic import io.leostrange.mrcomic.core.model.ComicFormat +import io.leostrange.mrcomic.core.model.displayReadingProgress import io.leostrange.mrcomic.core.data.db.entity.SavedQuote import io.leostrange.mrcomic.core.ui.theme.ReadingPreset import io.leostrange.mrcomic.core.ui.theme.ThemeMode @@ -374,7 +375,7 @@ internal class SettingsBackupController( put("addedDate", comic.addedDate) put("lastModified", comic.lastModified) put("folderId", comic.folderId) - put("readingProgress", comic.readingProgress.toDouble()) + put("readingProgress", comic.displayReadingProgress().toDouble()) put("lastReadDate", comic.lastReadDate ?: 0L) put("isBookmarked", comic.isBookmarked) put("tags", comic.tags) diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt index 32554aeca..6981c61d7 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsComponents.kt @@ -42,11 +42,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface -import io.leostrange.mrcomic.core.ui.designsystem.MrComicCompactValueRow -import io.leostrange.mrcomic.core.ui.designsystem.MrComicPanelCard +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItemTrailing import io.leostrange.mrcomic.core.ui.designsystem.MrComicPill -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSliderTile -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSwitchRow +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSectionHeader +import io.leostrange.mrcomic.core.ui.designsystem.MrComicSlider import io.leostrange.mrcomic.core.ui.library.rootChromeIconContainerColor import io.leostrange.mrcomic.core.ui.library.rootChromePillContainerColor import io.leostrange.mrcomic.core.ui.library.rootChromePillContentColor @@ -70,7 +70,18 @@ internal fun SettingsCard( title: String, content: @Composable ColumnScope.() -> Unit ) { - MrComicPanelCard(title = title, content = content) + // Editorial Ink: section header (no card frame). Sub-cards inside + // (e.g. AppearanceLanguageCard) still render their own previews, but + // the outer wrapper no longer adds a second layer of background and + // border. Subsequent passes will flatten the inner rows to MrComicListItem. + Column(modifier = Modifier.fillMaxWidth()) { + MrComicSectionHeader(title = title) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } } @Composable @@ -98,15 +109,19 @@ internal fun SwitchRow( onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true ) { - MrComicSwitchRow( + MrComicListItem( title = title, subtitle = subtitle, - checked = checked, enabled = enabled, - onCheckedChange = { value -> - UIFeedback.playSelect() - onCheckedChange(value) - } + onClick = null, + trailing = MrComicListItemTrailing.Switch( + checked = checked, + onCheckedChange = { value -> + UIFeedback.playSelect() + onCheckedChange(value) + }, + enabled = enabled, + ), ) } @@ -116,17 +131,16 @@ internal fun SettingsPickerTile( value: String, onClick: () -> Unit, subtitle: String? = null, - compact: Boolean = false + @Suppress("UNUSED_PARAMETER") compact: Boolean = false ) { - MrComicCompactValueRow( + MrComicListItem( title = title, subtitle = subtitle, - value = value, + trailing = MrComicListItemTrailing.Value(value), onClick = { UIFeedback.playSelect() onClick() }, - compact = compact ) } @@ -140,15 +154,24 @@ internal fun SettingsSliderTile( steps: Int = 0, subtitle: String? = null ) { - MrComicSliderTile( - title = title, - valueLabel = valueLabel, - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - steps = steps, - subtitle = subtitle - ) + Column(modifier = Modifier.fillMaxWidth()) { + MrComicListItem( + title = title, + subtitle = subtitle, + trailing = MrComicListItemTrailing.Value(valueLabel), + onClick = null, + divider = false, + ) + MrComicSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 4.dp), + ) + } } @Composable @@ -338,6 +361,7 @@ internal fun SettingsCompactSummaryCard( } } + @Composable internal fun SettingsPreviewBanner( title: String, diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt index 0a2d22497..8cc3ebcc8 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsLibraryPreviews.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import io.leostrange.mrcomic.core.ui.designsystem.MrComicCardSurface +import io.leostrange.mrcomic.core.ui.designsystem.MrComicPreviewBackdrop import io.leostrange.mrcomic.core.ui.library.LibraryBackdropLayer import io.leostrange.mrcomic.core.ui.library.LibraryShelfBar import io.leostrange.mrcomic.core.ui.library.libraryCardElevation @@ -64,6 +65,7 @@ internal fun SelectedLibraryBackgroundPreview( ?: imageUri } + MrComicPreviewBackdrop(shape = RoundedCornerShape(16.dp)) { MrComicCardSurface( modifier = modifier.fillMaxWidth(), cornerRadius = 16.dp, @@ -109,6 +111,7 @@ internal fun SelectedLibraryBackgroundPreview( ) } } + } } } @@ -131,6 +134,7 @@ internal fun LibraryStylePreview( val styleLabel = libraryBackgroundStyleLabel(uiState.libraryBackgroundStyle, uiState.appLanguage) val shelfLabel = libraryShelfStyleLabel(uiState.libraryShelfStyle, uiState.appLanguage) val shape = RoundedCornerShape(22.dp) + MrComicPreviewBackdrop(shape = shape) { MrComicCardSurface( modifier = modifier, fillMaxWidth = false, @@ -241,6 +245,7 @@ internal fun LibraryStylePreview( } } } + } } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt index f521ceb7e..32294c74e 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLabels.kt @@ -106,8 +106,20 @@ internal fun readerTapZoneActionLabel(language: String, action: String): String } internal fun readerInfoSlotPreviewValue(language: String, slot: String): String = when (ReaderInfoSlot.fromStored(slot)) { ReaderInfoSlot.NONE -> "" - ReaderInfoSlot.BOOK_TITLE -> if (language == "en") "Book title" else "Название книги" - ReaderInfoSlot.CHAPTER_TITLE -> if (language == "en") "Chapter 3" else "Глава 3" + ReaderInfoSlot.BOOK_TITLE -> when (language) { + "en" -> "Book title" + "ja" -> "本のタイトル" + "zh" -> "书名" + "ko" -> "책 제목" + else -> "Название книги" + } + ReaderInfoSlot.CHAPTER_TITLE -> when (language) { + "en" -> "Chapter 3" + "ja" -> "第3章" + "zh" -> "第3章" + "ko" -> "제3장" + else -> "Глава 3" + } ReaderInfoSlot.TIME -> "12:48" ReaderInfoSlot.PROGRESS -> "78%" ReaderInfoSlot.PAGE -> "124 / 320" diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt index 986d96a04..b234ad407 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderLayoutCards.kt @@ -446,7 +446,11 @@ internal fun ReaderModeCard( MrComicFilterChip( selected = uiState.readingMode == ReadingMode.PAGE_LTR || uiState.readingMode == ReadingMode.PAGE_RTL, - onClick = { viewModel.setReadingMode(ReadingMode.PAGE_LTR) }, + onClick = { + if (shouldApplyPagedReadingMode(uiState.readingMode)) { + viewModel.setReadingMode(ReadingMode.PAGE_LTR) + } + }, label = { Text(readerModeSettingsLabel(strings.languageCode, ReadingMode.PAGE_LTR)) } ) MrComicFilterChip( @@ -458,6 +462,10 @@ internal fun ReaderModeCard( } } +/** A grouped "pages" chip must be idempotent for both LTR and RTL modes. */ +internal fun shouldApplyPagedReadingMode(currentMode: ReadingMode): Boolean = + currentMode != ReadingMode.PAGE_LTR && currentMode != ReadingMode.PAGE_RTL + /* ──── ReaderImageLayoutCard ──── */ @Composable internal fun ReaderImageLayoutCard( @@ -465,14 +473,7 @@ internal fun ReaderImageLayoutCard( language: String, viewModel: SettingsViewModel ) { - SettingsCard(title = readerImageLayoutCardTitle(language)) { - Text( - readerImageLayoutCardHint(language), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(8.dp)) - LabelText(readerImageScaleModeTitle(language)) + SettingsCard(title = readerImageScaleModeTitle(language)) { ChipRow { ReaderImageScaleMode.entries.forEach { mode -> MrComicFilterChip( @@ -482,24 +483,6 @@ internal fun ReaderImageLayoutCard( ) } } - Spacer(Modifier.height(6.dp)) - SettingsSliderTile( - title = readerMarginCropHorizontalTitle(language), - valueLabel = readerMarginCropPercentLabel(uiState.readerImageMarginCropHorizontal), - value = uiState.readerImageMarginCropHorizontal, - onValueChange = viewModel::setReaderImageMarginCropHorizontal, - valueRange = 0f..0.22f, - steps = 10 - ) - Spacer(Modifier.height(8.dp)) - SettingsSliderTile( - title = readerMarginCropVerticalTitle(language), - valueLabel = readerMarginCropPercentLabel(uiState.readerImageMarginCropVertical), - value = uiState.readerImageMarginCropVertical, - onValueChange = viewModel::setReaderImageMarginCropVertical, - valueRange = 0f..0.22f, - steps = 10 - ) } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt index 978cb98ab..e9f5ee4eb 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderSection.kt @@ -103,11 +103,13 @@ internal fun ReaderSection( } } ReaderSettingsPage.TEXT_APPEARANCE -> { - item { - ReaderTextAppearancePreviewCard( - uiState = uiState, - strings = strings - ) + stickyHeader(key = "reader_text_appearance_preview") { + Box(modifier = Modifier.padding(bottom = 10.dp)) { + ReaderTextAppearancePreviewCard( + uiState = uiState, + strings = strings + ) + } } item { ReaderTextStyleCard( @@ -124,9 +126,6 @@ internal fun ReaderSection( } } ReaderSettingsPage.PAGE_LAYOUT -> { - item { - ReaderPageLayoutPreviewCard(uiState = uiState, strings = strings) - } item { ReaderModeCard(uiState = uiState, strings = strings, viewModel = viewModel) } @@ -155,8 +154,10 @@ internal fun ReaderSection( } } ReaderSettingsPage.PAGING -> { - item { - ReaderPagingPreviewCard(uiState = uiState, language = uiState.appLanguage) + stickyHeader(key = "reader_paging_preview") { + Box(modifier = Modifier.padding(bottom = 10.dp)) { + ReaderPagingPreviewCard(uiState = uiState, language = uiState.appLanguage) + } } item { ReaderPagingSettingsCard( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt index ee72a6abb..301702aef 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsReaderTextCards.kt @@ -174,7 +174,7 @@ internal fun ReaderTextStyleCard( modifier = Modifier.fillMaxWidth(), variant = MrComicButtonVariant.Outlined ) { - Text(if (strings.languageCode == "ru") "Сохранить текущий стиль как новый" else "Save current style as new") + Text(styleText.savedStyleSave) } Spacer(Modifier.height(8.dp)) Column( diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt index e2610f94f..0063f09fe 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsTranslationSection.kt @@ -20,7 +20,8 @@ import androidx.compose.ui.unit.dp import io.leostrange.mrcomic.core.ui.designsystem.MrComicButton import io.leostrange.mrcomic.core.ui.designsystem.MrComicButtonVariant import io.leostrange.mrcomic.core.ui.designsystem.MrComicFilterChip -import io.leostrange.mrcomic.core.ui.designsystem.MrComicSwitchRow +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItem +import io.leostrange.mrcomic.core.ui.designsystem.MrComicListItemTrailing import io.leostrange.mrcomic.core.ui.locale.AppStrings import io.leostrange.mrcomic.core.ui.locale.ocrSourceLanguageOptions import io.leostrange.mrcomic.core.ui.locale.translationLanguageOptions @@ -344,19 +345,21 @@ internal fun OcrFiltersCard( ) { SettingsCard(title = sectionText.comicFiltersCard) { LabelText(sectionText.comicFiltersHint) - Spacer(Modifier.height(8.dp)) - MrComicSwitchRow( + MrComicListItem( title = sectionText.dialoguesOnlyTitle, subtitle = sectionText.dialoguesOnlySubtitle, - checked = uiState.ocrDialoguesOnly, - onCheckedChange = viewModel::setOcrDialoguesOnly + trailing = MrComicListItemTrailing.Switch( + checked = uiState.ocrDialoguesOnly, + onCheckedChange = viewModel::setOcrDialoguesOnly, + ), ) - Spacer(Modifier.height(12.dp)) - MrComicSwitchRow( + MrComicListItem( title = sectionText.includeSfxTitle, subtitle = sectionText.includeSfxSubtitle, - checked = uiState.ocrIncludeSfx, - onCheckedChange = viewModel::setOcrIncludeSfx + trailing = MrComicListItemTrailing.Switch( + checked = uiState.ocrIncludeSfx, + onCheckedChange = viewModel::setOcrIncludeSfx + ), ) } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt index 85e8501c3..0ba21bb5c 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModel.kt @@ -33,7 +33,6 @@ import io.leostrange.mrcomic.core.ui.theme.style import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import io.leostrange.mrcomic.core.data.dictionary.DictionaryDownloader -import io.leostrange.mrcomic.core.data.dictionary.DictionaryInstallInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -47,7 +46,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File import javax.inject.Inject // Preset data classes and parsing extracted to SettingsPresets.kt @@ -178,12 +176,6 @@ class SettingsViewModel @Inject constructor( // the init block leaves the backing field null during constructor startup. internal val _dictionaryDownloadState = MutableStateFlow(DictionaryDownloadState()) val dictionaryDownloadState: StateFlow = _dictionaryDownloadState - internal val _dictionaryItems = MutableStateFlow>(emptyList()) - internal val _dictionaryOperationState = MutableStateFlow(DictionaryOperationState.Idle) - internal val _pendingDownloadLanguage = MutableStateFlow(null) - internal val _dictionaryDownloadProgress = MutableStateFlow(null) - internal val _pendingImportFile = MutableStateFlow(null) - internal val _needsImportLanguageSelection = MutableStateFlow(false) init { viewModelScope.launch { diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt index 17326d145..0f4fc7d9e 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelDictionary.kt @@ -5,18 +5,16 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.ContentResolver import android.net.Uri -import android.util.Log import androidx.lifecycle.viewModelScope import io.leostrange.mrcomic.core.data.dictionary.DictionaryInstallInfo import io.leostrange.mrcomic.core.data.dictionary.DictionaryAssetCatalog import io.leostrange.mrcomic.core.ui.locale.DictionaryStrings import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream @@ -59,6 +57,10 @@ val SettingsViewModel.dictionaryOperationState: StateFlow get() = _pendingDownloadLanguage +private val _dictionaryItems = MutableStateFlow>(emptyList()) +private val _dictionaryOperationState = MutableStateFlow(DictionaryOperationState.Idle) +private val _pendingDownloadLanguage = MutableStateFlow(null) + // ─── Init hook (call from SettingsViewModel.init) ───────────────────────────── fun SettingsViewModel.initDictionaryState() { @@ -93,14 +95,14 @@ fun SettingsViewModel.cancelPendingDownload() { fun SettingsViewModel.cancelPendingImport() { _needsImportLanguageSelection.value = false - _pendingImportFile.value?.delete() - _pendingImportFile.value = null + _pendingImportBytes.value = null _dictionaryOperationState.value = DictionaryOperationState.Idle } // ─── Single dictionary download ─────────────────────────────────────────────── // Per-language download progress (0f–1f). null = no active download. +private val _dictionaryDownloadProgress = MutableStateFlow(null) val SettingsViewModel.dictionaryDownloadProgress: StateFlow get() = _dictionaryDownloadProgress @@ -131,9 +133,8 @@ fun SettingsViewModel.downloadDictionary(lang: String) { ) } } - } catch (error: Exception) { - Log.e(DICTIONARY_LOG_TAG, "Dictionary download failed for $lang", error) - statusState.update { it.copy(message = formatDictionaryOpError()) } + } catch (_: Exception) { + // error swallowed — UI stays on Idle after finally } finally { _dictionaryOperationState.value = DictionaryOperationState.Idle _dictionaryDownloadProgress.value = null @@ -152,9 +153,6 @@ fun SettingsViewModel.deleteDictionary(lang: String) { _dictionaryOperationState.value = DictionaryOperationState.Deleting(lang) try { withContext(Dispatchers.IO) { dictionaryDownloader.deleteDictionary(lang) } - } catch (error: Exception) { - Log.e(DICTIONARY_LOG_TAG, "Dictionary deletion failed for $lang", error) - statusState.update { it.copy(message = formatDictionaryOpError()) } } finally { _dictionaryOperationState.value = DictionaryOperationState.Idle refreshDictionaryItems() @@ -177,9 +175,6 @@ fun SettingsViewModel.exportDictionary(lang: String, uri: Uri, contentResolver: statusState.update { it.copy(message = if (success) formatDictionaryExportSuccess() else formatDictionaryOpError()) } - } catch (error: Exception) { - Log.e(DICTIONARY_LOG_TAG, "Dictionary export failed for $lang", error) - statusState.update { it.copy(message = formatDictionaryOpError()) } } finally { _dictionaryOperationState.value = DictionaryOperationState.Idle } @@ -202,9 +197,6 @@ fun SettingsViewModel.importDictionary(lang: String, uri: Uri, contentResolver: it.copy(message = if (result != null) formatDictionaryImportSuccess() else formatDictionaryImportInvalid()) } if (result != null) refreshDictionaryItems() - } catch (error: Exception) { - Log.e(DICTIONARY_LOG_TAG, "Dictionary import failed for $lang", error) - statusState.update { it.copy(message = formatDictionaryImportInvalid()) } } finally { _dictionaryOperationState.value = DictionaryOperationState.Idle } @@ -227,9 +219,7 @@ fun SettingsViewModel.exportAllDictionaries(uri: Uri, contentResolver: ContentRe for (info in installed) { val entryName = "dictionary_${info.language}.dbpack" zos.putNextEntry(ZipEntry(entryName)) - if (!dictionaryDownloader.exportDictionary(info.language, zos)) { - throw IOException("Could not export dictionary ${info.language}") - } + dictionaryDownloader.exportDictionary(info.language, zos) zos.closeEntry() } } @@ -239,9 +229,6 @@ fun SettingsViewModel.exportAllDictionaries(uri: Uri, contentResolver: ContentRe statusState.update { it.copy(message = if (success) formatDictionaryExportSuccess() else formatDictionaryOpError()) } - } catch (error: Exception) { - Log.e(DICTIONARY_LOG_TAG, "Dictionary archive export failed", error) - statusState.update { it.copy(message = formatDictionaryOpError()) } } finally { _dictionaryOperationState.value = DictionaryOperationState.Idle } @@ -255,31 +242,13 @@ fun SettingsViewModel.importDictionaryFromUri(uri: Uri, contentResolver: Content viewModelScope.launch { _dictionaryOperationState.value = DictionaryOperationState.Importing("__auto__") try { - val importFile = withContext(Dispatchers.IO) { - val target = File.createTempFile("dictionary_import_", ".tmp", context.cacheDir) - val copied = try { - contentResolver.openInputStream(uri)?.use { input -> - target.outputStream().use { output -> input.copyTo(output) } - true - } ?: false - } catch (error: Exception) { - target.delete() - throw error - } - if (copied && target.length() > 0L) { - target - } else { - target.delete() - null - } + val bytes = withContext(Dispatchers.IO) { + contentResolver.openInputStream(uri)?.use { it.readBytes() } } - when (detectDictionaryImportKind(importFile)) { + when (detectDictionaryImportKind(bytes)) { DictionaryImportKind.ZIP -> { - val zipFile = requireNotNull(importFile) - val imported = try { - withContext(Dispatchers.IO) { importFromZip(zipFile) } - } finally { - zipFile.delete() + val imported = withContext(Dispatchers.IO) { + importFromZip(requireNotNull(bytes)) } statusState.update { it.copy(message = if (imported) formatDictionaryImportSuccess() else formatDictionaryImportInvalid()) @@ -288,60 +257,57 @@ fun SettingsViewModel.importDictionaryFromUri(uri: Uri, contentResolver: Content _dictionaryOperationState.value = DictionaryOperationState.Idle } DictionaryImportKind.SINGLE -> { - // Keep a disk-backed copy while the user chooses the language; - // dictionary modules are too large to retain safely in RAM. - _pendingImportFile.value = requireNotNull(importFile) + // SAF streams are scoped to the activity result callback. Keep bytes, + // not the closed stream, while the user chooses the language. + _pendingImportBytes.value = requireNotNull(bytes) _needsImportLanguageSelection.value = true _dictionaryOperationState.value = DictionaryOperationState.Idle } DictionaryImportKind.INVALID -> { - importFile?.delete() statusState.update { it.copy(message = formatDictionaryImportInvalid()) } _dictionaryOperationState.value = DictionaryOperationState.Idle } } - } catch (error: Exception) { - Log.e(DICTIONARY_LOG_TAG, "Dictionary import detection failed", error) + } catch (_: Exception) { statusState.update { it.copy(message = formatDictionaryImportInvalid()) } - } finally { _dictionaryOperationState.value = DictionaryOperationState.Idle + } finally { + // Every branch above sets the operation back to Idle. This is also + // required after a SAF/provider failure so the import button recovers. } } } /** Called after the user picks a language for the pending single-file import. */ fun SettingsViewModel.completePendingImport(lang: String) { - val importFile = _pendingImportFile.value ?: return + val bytes = _pendingImportBytes.value ?: return _needsImportLanguageSelection.value = false - _pendingImportFile.value = null + _pendingImportBytes.value = null viewModelScope.launch { _dictionaryOperationState.value = DictionaryOperationState.Importing(lang) try { val result = withContext(Dispatchers.IO) { - importFile.inputStream().use { input -> - dictionaryDownloader.importDictionary(lang, input) - } + dictionaryDownloader.importDictionary(lang, bytes.inputStream()) } statusState.update { it.copy(message = if (result != null) formatDictionaryImportSuccess() else formatDictionaryImportInvalid()) } if (result != null) refreshDictionaryItems() - } catch (error: Exception) { - Log.e(DICTIONARY_LOG_TAG, "Pending dictionary import failed for $lang", error) - statusState.update { it.copy(message = formatDictionaryImportInvalid()) } } finally { - importFile.delete() _dictionaryOperationState.value = DictionaryOperationState.Idle } } } +private val _pendingImportBytes = MutableStateFlow(null) +private val _needsImportLanguageSelection = MutableStateFlow(false) + val SettingsViewModel.needsImportLanguageSelection: StateFlow get() = _needsImportLanguageSelection -private fun SettingsViewModel.importFromZip(file: File): Boolean { +private fun SettingsViewModel.importFromZip(bytes: ByteArray): Boolean { var anyImported = false - ZipInputStream(file.inputStream().buffered()).use { zis -> + ZipInputStream(bytes.inputStream()).use { zis -> var entry = zis.nextEntry while (entry != null) { val name = entry.name @@ -352,7 +318,8 @@ private fun SettingsViewModel.importFromZip(file: File): Boolean { else -> null } if (lang != null && DictionaryAssetCatalog.configForLanguage(lang) != null) { - val result = dictionaryDownloader.importDictionary(lang, zis) + val tempBytes = zis.readBytes() + val result = dictionaryDownloader.importDictionary(lang, tempBytes.inputStream()) if (result != null) anyImported = true } entry = zis.nextEntry @@ -375,22 +342,9 @@ internal fun detectDictionaryImportKind(bytes: ByteArray?): DictionaryImportKind return if (isZip) DictionaryImportKind.ZIP else DictionaryImportKind.SINGLE } -private fun detectDictionaryImportKind(file: File?): DictionaryImportKind { - if (file == null || !file.isFile || file.length() == 0L) return DictionaryImportKind.INVALID - val header = file.inputStream().use { input -> - byteArrayOf(input.read().toByte(), input.read().toByte()) - } - return if (header[0] == 0x50.toByte() && header[1] == 0x4B.toByte()) { - DictionaryImportKind.ZIP - } else { - DictionaryImportKind.SINGLE - } -} - // ─── Download notification helpers ──────────────────────────────────────────── private const val DICT_NOTIFICATION_CHANNEL_ID = "downloads" -private const val DICTIONARY_LOG_TAG = "SettingsDictionary" private const val DICT_NOTIFICATION_BASE_ID = 0xD1C7 // 53703 — distinctive base private var nextDictNotifSlot = 0 diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt index 82dfd7724..2566d2dd8 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelFlows.kt @@ -493,17 +493,19 @@ internal fun SettingsUiStateFlowBuilder.createCombinedSettingsUiState(): Flow state.copy(readerImageScaleMode = imageScaleMode) }.combine( - preferences.get( - PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, - 0f - ).map { it.coerceIn(0f, 0.22f) } + combine( + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, 0f) + ) { left, right, legacy -> resolveMarginCropDisplay(left, right, legacy) } ) { state: SettingsUiState, horizontalCrop: Float -> state.copy(readerImageMarginCropHorizontal = horizontalCrop) }.combine( - preferences.get( - PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, - 0f - ).map { it.coerceIn(0f, 0.22f) } + combine( + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, -1f), + preferences.get(PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, 0f) + ) { top, bottom, legacy -> resolveMarginCropDisplay(top, bottom, legacy) } ) { state: SettingsUiState, verticalCrop: Float -> state.copy(readerImageMarginCropVertical = verticalCrop) }.combine( @@ -561,4 +563,16 @@ internal fun SettingsUiStateFlowBuilder.createCombinedSettingsUiState(): Flow= 0f && second >= 0f -> ((first + second) / 2f).coerceIn(0f, 0.22f) + first >= 0f -> first.coerceIn(0f, 0.22f) + second >= 0f -> second.coerceIn(0f, 0.22f) + else -> legacy.coerceIn(0f, 0.22f) +} + diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt index 4b8fc14ef..3f7d69dbc 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelReaderSetters.kt @@ -23,6 +23,7 @@ internal fun SettingsSettersController.setReaderPreset(presetName: String) { preferences.set(PreferencesKeys.READER_PAGE_ANIMATION, style.pageAnimation) preferences.set(PreferencesKeys.READER_PAGE_SOUND, false) preferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, style.textColorScheme) + preferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, style.textColorScheme) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_TEXT_COLOR, null) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_BACKGROUND_COLOR, null) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_ACCENT_COLOR, null) @@ -91,19 +92,25 @@ internal fun SettingsSettersController.setReaderImageScaleMode(mode: String) { internal fun SettingsSettersController.setReaderImageMarginCropHorizontal(value: Float) { setSlider("readerImageMarginCropHorizontal") { - preferences.set( - PreferencesKeys.READER_PAGE_MARGIN_CROP_HORIZONTAL, - value.coerceIn(0f, 0.22f) - ) + val safe = value.coerceIn(0f, 0.22f) + // Symmetric pair write: the reader stores per-side values. + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_LEFT, safe) + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_RIGHT, safe) + // Moving a crop slider in settings implies wanting the crop active. + if (safe > 0f) { + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, true) + } } } internal fun SettingsSettersController.setReaderImageMarginCropVertical(value: Float) { setSlider("readerImageMarginCropVertical") { - preferences.set( - PreferencesKeys.READER_PAGE_MARGIN_CROP_VERTICAL, - value.coerceIn(0f, 0.22f) - ) + val safe = value.coerceIn(0f, 0.22f) + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_TOP, safe) + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_BOTTOM, safe) + if (safe > 0f) { + preferences.set(PreferencesKeys.READER_PAGE_MARGIN_CROP_ENABLED, true) + } } } diff --git a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt index a37075eb6..4562aff02 100644 --- a/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt +++ b/android/feature-settings/src/main/java/io/leostrange/mrcomic/feature/settings/ui/SettingsViewModelTextSetters.kt @@ -113,6 +113,7 @@ internal suspend fun SettingsSettersController.importReaderTypographyFromJson(ra preferences.set(PreferencesKeys.READER_PRESET, imported.readerPreset.name) preferences.set(PreferencesKeys.TEXT_FONT_SIZE, imported.textFontSize) preferences.set(PreferencesKeys.TEXT_COLOR_SCHEME, imported.textColorScheme) + preferences.set(PreferencesKeys.GRAPHIC_COLOR_SCHEME, imported.textColorScheme) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_TEXT_COLOR, imported.textCustomTextColor) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_BACKGROUND_COLOR, imported.textCustomBackgroundColor) persistNullableReaderColor(PreferencesKeys.TEXT_CUSTOM_ACCENT_COLOR, imported.textCustomAccentColor) diff --git a/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/ReaderModeSelectionPolicyTest.kt b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/ReaderModeSelectionPolicyTest.kt new file mode 100644 index 000000000..805020ace --- /dev/null +++ b/android/feature-settings/src/test/java/io/leostrange/mrcomic/feature/settings/ui/ReaderModeSelectionPolicyTest.kt @@ -0,0 +1,20 @@ +package io.leostrange.mrcomic.feature.settings.ui + +import io.leostrange.mrcomic.core.model.ReadingMode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderModeSelectionPolicyTest { + + @Test + fun repeatedPagesSelectionDoesNotChangeLtrOrRtl() { + assertFalse(shouldApplyPagedReadingMode(ReadingMode.PAGE_LTR)) + assertFalse(shouldApplyPagedReadingMode(ReadingMode.PAGE_RTL)) + } + + @Test + fun pagesSelectionCanLeaveWebtoonMode() { + assertTrue(shouldApplyPagedReadingMode(ReadingMode.WEBTOON)) + } +} diff --git a/docs/bug-analysis-report.md b/docs/bug-analysis-report.md new file mode 100644 index 000000000..0a3c71aee --- /dev/null +++ b/docs/bug-analysis-report.md @@ -0,0 +1,584 @@ +# Mr.Comic Bug Analysis Report + +**Date:** 2026-08-19 +**Scope:** 17 active bugs from video analysis, bug tracker, and APK static analysis +**Excluded:** Autoscroller viewport defect (fixed), black bar during autoscroller (fixed), Settings → Translation → Dictionaries crash (excluded by owner) + +--- + +## Executive Summary + +After thorough codebase analysis, I've identified root causes and current implementation state for all 17 bugs. The most critical issues are: + +1. **P0/P1 bugs (7):** Position persistence, progress synchronization, TOC navigation, footnotes vs gestures, quote navigation +2. **P2 bugs (7):** Text selection, padding consistency, reading mode/theme coupling, UI consistency, preview component +3. **P3 bugs (2):** HTML title overflow, seekbar desync + +The codebase shows a well-structured reader architecture with separate rendering paths for raster/vertical/text content, but several state synchronization issues remain unresolved. + +--- + +## Detailed Bug Analysis + +### Reader / Vertical Mode + +#### BUG-VERTICAL-01 — Seekbar/Position Desync +**Priority:** P1 | **Area:** Reader State / Scroll / Progress + +**Root Cause Analysis:** +The seekbar (Slider) in `ReaderBottomBar.kt` uses `currentPage` from `_uiState`, while the actual scroll position in vertical mode is tracked separately by: +- `freeScrollProgression` (0..1) for text webtoon +- `freeScrollCharacterOffset` for character-based positioning +- `sectionCharacterOffset` for paged text + +The seekbar value is NOT derived from the scroll state directly—it's a separate `currentPage` integer that gets updated via `navigationController` callbacks. In vertical mode, the WebView's scroll position may update without properly syncing back to the seekbar. + +**Current Implementation:** +```kotlin +// ReaderBottomBar.kt:142 +Slider( + value = freeScrollProgression.toFloat().coerceIn(0f, 1f), + onValueChange = { onProgressionChange?.invoke(it) }, + ... +) +``` + +The seekbar has two paths: +1. Text webtoon: uses `freeScrollProgression` (continuous 0..1) +2. Paged mode: uses `currentPage` integer + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/components/ReaderBottomBar.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderChromeBottomPanel.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderViewModel.kt` (onFreeScrollPositionChanged) + +**Suggested Fix:** +Unify position state: DocumentPosition → ScrollPosition → ReadingProgress → SeekBar. The seekbar should derive its value from a single source of truth (either the scroll fraction or the normalized progress), not maintain independent state. + +--- + +### Reader / Paged Mode + +#### BUG-PAGED-01 — Random Text Selection on Swipe +**Priority:** P2 | **Area:** Gesture / Text Selection + +**Root Cause Analysis:** +`PagedGesturePolicy.classifyPagedGesture()` prioritizes selection over page turns when `hasActiveSelection` is true. However, the WebView may initiate selection on a slight finger movement before the gesture is classified as a swipe. + +The policy checks: +```kotlin +if (hasActiveSelection) return PagedGestureAction.PASS_THROUGH +``` + +This means once selection starts, ALL subsequent events pass through to WebView, preventing page turns. + +**Current Implementation:** +- Selection suppression on move: `shouldSuppressSelectionOnMove` checks `hasMoved && !hasActiveSelection` +- Move interception: `shouldInterceptMove` has different thresholds for vertical (8px) and horizontal (48px) + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/gesture/PagedGesturePolicy.kt` +- WebView JavaScript bridge (selection detection) + +**Suggested Fix:** +Add a selection initiation delay or require long-press before allowing selection. The gesture policy should have a "selection lockout" period after touch start (e.g., 300ms) to differentiate tap/swipe from intentional selection. + +--- + +#### BUG-PAGED-02 — Uneven Top/Bottom Padding +**Priority:** P2 | **Area:** Layout / Pagination / Insets + +**Root Cause Analysis:** +The viewport calculation for paged text uses: +``` +Screen height − System Insets − Reader Insets − Reader Padding = Page Viewport +``` + +However, `ChromeInsetsPlan.kt` and `ReaderViewportGeometry.kt` may calculate insets differently depending on: +1. Whether system bars are visible +2. Whether the reader chrome is shown +3. Display cutout handling + +The padding is not centralized—it's applied at multiple levels (Compose layout, WebView padding, CSS margins). + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ChromeInsetsPlan.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderViewportGeometry.kt` +- `android/feature-reader/src/main/java/.../ui/PagedLayoutParams.kt` +- `android/feature-reader/src/main/java/.../ui/PagedViewportContract.kt` + +**Suggested Fix:** +Centralize viewport calculation in a single `ViewportCalculator` that takes all inputs (screen, system insets, reader insets, padding) and returns a consistent viewport rect. Apply this rect uniformly across all rendering paths. + +--- + +#### BUG-PAGED-03 — Footnotes Conflict with Page Gesture Zones +**Priority:** P1 | **Area:** Gesture / Hit Testing / Footnotes + +**Root Cause Analysis:** +The footnote controller (`ReaderFootnoteController.kt`) handles anchor clicks via JavaScript bridge: +```kotlin +fun onAnchorClick(href: String) { ... } +``` + +However, the paged gesture policy intercepts touches BEFORE the WebView can process them as clicks. The policy's priority is: +1. Active selection → PASS_THROUGH +2. Touch on link → PASS_THROUGH +3. Edge tap → TAP_LEFT/TAP_RIGHT + +The issue is that footnotes near screen edges fall into the "edge tap" zone (12% from each side), so they get consumed as page turns instead of footnote clicks. + +**Current Implementation:** +```kotlin +// ReaderWebViewJavaScript.kt:195 +// checks this flag to avoid consuming footnote clicks as page turns. +``` + +The WebView JavaScript sets a flag when a footnote is detected, but this happens AFTER the touch event is already consumed by the gesture policy. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/gesture/PagedGesturePolicy.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderFootnoteController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderWebViewJavaScript.kt` + +**Suggested Fix:** +Implement a hit-test priority system: +1. Footnote/Link detection (via JavaScript hit test) +2. Interactive content +3. Selection +4. Page navigation + +The gesture policy should query the WebView for link/footnote presence BEFORE classifying the gesture as a page turn. + +--- + +### Reader State / Pagination / Navigation + +#### BUG-READER-01 — Incorrect Page Count +**Priority:** P1 | **Area:** Pagination Engine + +**Root Cause Analysis:** +Page counts are tracked at multiple levels: +1. `_uiState.totalPages` — raw page count from format reader +2. `sectionPageCounts` — EPUB section page counts (`EpubSectionPageCountStore`) +3. `epubAccumulatedTotalPages` — estimated total visual pages +4. `progressController.totalBookSections` — spine section count + +These values can diverge because: +- EPUB pages are calculated dynamically as sections are paginated +- The deferred page count policy (`DeferredPageCountPolicy`) uses provisional values +- Raster formats count images, text formats count visual pages + +**Current Implementation:** +```kotlin +// ReaderProgressController.kt +fun accumulatedTotalPagesForEpub(): Int { + return EpubProgressCalculator.estimatedTotalPages( + sectionPageCounts = sectionPageCounts.snapshot(), + totalSections = totalBookSections + ) +} +``` + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderProgressController.kt` +- `android/feature-reader/src/main/java/.../domain/progress/EpubSectionPageCountStore.kt` +- `android/feature-reader/src/main/java/.../domain/progress/EpubProgressCalculator.kt` +- `android/feature-reader/src/main/java/.../ui/DeferredPageCountPolicy.kt` + +**Suggested Fix:** +Separate logical position from visual page count. Create a unified `PaginationState` that: +1. Tracks spine sections (stable) +2. Tracks visual pages per section (dynamic) +3. Computes progress as a fraction (0..1) +4. Never mixes chapter/section/document page counts + +--- + +#### BUG-READER-02 — Reading Mode Not Persisted +**Priority:** P1 | **Area:** Persistence / Reader Preferences + +**Root Cause Analysis:** +The reading mode is saved in `setReadingMode()`: +```kotlin +viewModelScope.launch { + readerPreferences.set(PreferencesKeys.READING_MODE, mode.name) +} +``` + +But the restore logic in `configureOpening()` prefers the mode from the structured position: +```kotlin +val openingMode = restoredPosition?.mode?.takeIf { mode -> + !readerRendersHtmlContent || mode != ReadingMode.DUAL_PAGE +} ?: configuredOpeningMode +``` + +If the structured position has a different mode than the user's preference, the position's mode wins. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderReadingModeController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderBookOpeningController.kt` +- `android/core-data/src/main/java/.../preferences/UserPreferences.kt` + +**Suggested Fix:** +Persist reading mode per-book (not globally). When opening a book: +1. Check per-book saved mode +2. If no per-book mode, use the structured position's mode +3. If neither, use the global preference +4. Always save the mode back to per-book storage on change + +--- + +#### BUG-READER-03 — Position Not Restored (P0) +**Priority:** P0 | **Area:** Persistence / Reading Position + +**Root Cause Analysis:** +The position saving has a dedup guard: +```kotlin +if (pending == pendingProgressSave || + isSamePersistedPosition(lastPersistedPositionJson, positionJson) +) return +``` + +During rapid exit, the WebView may not have reported its latest scroll position, so the snapshot appears identical to the last persisted value. The fix (`forceSavePositionOnClose`) bypasses this check: +```kotlin +// BUG-READER-03: use forceSavePositionOnClose instead of savePositionSnapshot +private fun forceSavePositionOnClose() { ... } +``` + +**Current State:** The fix is IMPLEMENTED but may not cover all edge cases: +- Process kill without proper close +- WebView not reporting scroll position before close +- Race condition between scroll callback and close + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderProgressController.kt` +- `android/feature-reader/src/main/java/.../domain/progress/ReaderPosition.kt` +- `android/feature-reader/src/main/java/.../domain/progress/ReaderPositionCodec.kt` + +**Suggested Fix:** +1. Add periodic position snapshots (every 5 seconds during active reading) +2. Save position on every page turn (not just on close) +3. Use `onPause` lifecycle callback to force save +4. Consider using WorkManager for reliable persistence + +--- + +#### BUG-READER-04 — Global Progress Desync (P0/P1) +**Priority:** P0/P1 | **Area:** Reader State / Progress + +**Root Cause Analysis:** +Progress is displayed in multiple places: +1. Chrome toolbar (page counter) +2. Bottom bar (slider + percentage) +3. File info sheet +4. Library card (reading progress badge) + +Each uses different calculation: +- Chrome: `currentPage / totalPages` +- Bottom bar: `effectiveCurrentPage / effectiveTotalPages` (with EPUB accumulation) +- Library: `comic.readingProgress` (0..1 float from database) + +These diverge because: +- EPUB pages are calculated dynamically +- The database stores a snapshot that may be stale +- Different components read from different state sources + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderProgressController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderBottomBar.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderChromeComponents.kt` +- `android/core-data/src/main/java/.../repository/LibraryRepository.kt` + +**Suggested Fix:** +Create a unified `ReadingProgressModel` that: +1. Computes progress from the same source (DocumentPosition) +2. Updates all consumers atomically +3. Separates display progress from persistence progress +4. Never mixes different page count sources + +--- + +#### BUG-READER-05 — Mode Change Resets Theme Preset +**Priority:** P2 | **Area:** State Isolation / Theme + +**Root Cause Analysis:** +The reading mode controller and theme preset controller are separate, but they share state through `_uiState`. When switching modes, the `applyReadingMode()` function updates multiple state fields, and the theme preset may get reset if: +1. The preset depends on the reading mode +2. The state update triggers a recomposition that resets the preset + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderReadingModeController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderStylePresetStorage.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderSettingsController.kt` + +**Suggested Fix:** +Ensure ReadingMode and ReaderTheme are completely independent state. The theme preset should be persisted separately and restored independently of the reading mode. + +--- + +#### BUG-READER-06 — HTML Title Overflow +**Priority:** P3 | **Area:** HTML Reader / Layout + +**Root Cause Analysis:** +The title in `ReaderMinimalBar` and `ReaderExpandedBar` uses: +```kotlin +Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ... +) +``` + +This should handle overflow, but the issue may be: +1. The title is set before the layout is measured +2. The parent container doesn't constrain width properly +3. HTML titles may contain special characters that break layout + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderChromeComponents.kt` + +**Suggested Fix:** +Verify the parent container constrains width. Add `fillMaxWidth()` modifier and ensure the title text is truncated properly. + +--- + +#### BUG-READER-07 — TOC Not Working in Some Formats +**Priority:** P1 | **Area:** TOC / Document Navigation + +**Root Cause Analysis:** +TOC entries are loaded via `pageCacheController.loadToc()` and stored in `_uiState.tableOfContents`. Navigation uses: +```kotlin +navigateTo(pageIdx, ReaderNavigationProgressSource.JUMP) +``` + +The issue is that `formatReader()?.resolveHrefToPage()` may return null or incorrect page indices for: +- EPUB files with complex spine structures +- FB2 files with non-linear chapter organization +- HTML files with fragment-based navigation + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderPageCacheController.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderFootnoteController.kt` +- `android/engine-api/src/main/java/.../FormatReader.kt` (interface) +- `android/engine-formats/src/main/kotlin/.../formats/` (implementations) + +**Suggested Fix:** +Unify TOC resolution across formats: +1. EPUB: Use Readium's locator API +2. FB2: Map chapter IDs to page indices +3. HTML: Resolve fragment anchors to scroll positions +4. All: Support both page-based and anchor-based navigation + +--- + +### Library / Visual System + +#### BUG-UI-01 — Inconsistent Library Card Badges +**Priority:** P2 | **Area:** Design System / Library + +**Root Cause Analysis:** +Format badges use `MrComicFormatBadge` from `core-ui`, but the implementation may: +1. Use different corner radius for different formats +2. Apply different alpha values for contrast +3. Not account for cover image brightness + +**Files Involved:** +- `android/core-ui/src/main/java/.../designsystem/MrComicFormatBadge.kt` +- `android/feature-library/src/main/java/.../components/LibraryContentDecor.kt` +- `android/feature-library/src/main/java/.../components/ComicGridItem.kt` + +**Suggested Fix:** +Standardize badge design tokens: +- Fixed corner radius (e.g., 4.dp) +- Guaranteed contrast ratio (4.5:1 minimum) +- Consistent padding and typography +- Background blur for readability over covers + +--- + +#### BUG-UI-02 — Day Preset Incorrect in Graphic Reader +**Priority:** P2 | **Area:** Graphic Reader / Theme + +**Root Cause Analysis:** +The `ReaderColorScheme.paletteForPreset()` function maps presets to colors: +```kotlin +scheme == "DAY" && readerPreset == ReadingPreset.NEWSPAPER -> "#f1eee7" to "#202020" +scheme == "DAY" && readerPreset == ReadingPreset.PAPER -> "#f6f1e7" to "#2b2118" +``` + +The default "DAY" falls through to: +```kotlin +else -> palette(scheme) // returns "#fafafa" to "#1a1a1a" +``` + +If the preset isn't properly saved or restored, the Day preset may show as default white instead of the intended warm tone. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/gesture/ReaderColorScheme.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderStylePresetStorage.kt` + +**Suggested Fix:** +Verify preset persistence and ensure the Day preset maps to the correct color palette. Add logging to track which preset is being applied. + +--- + +#### BUG-UI-04 — Inconsistent Background/Surface Colors +**Priority:** P1 | **Area:** Theme / Library / Contrast + +**Root Cause Analysis:** +The theme token pipeline is fragmented: +1. Reader uses `ReaderColorScheme` for content +2. Library uses Material 3 `colorScheme` +3. Chrome surfaces use custom colors + +When the user changes the background, not all surfaces update because they use different color sources. + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderScreen.kt` +- `android/feature-library/src/main/java/.../ui/LibraryScreen.kt` +- `android/core-ui/src/main/java/.../theme/` + +**Suggested Fix:** +Create a unified theme token pipeline: +1. Define all surface colors in one place +2. Propagate changes through MaterialTheme +3. Ensure reader, library, and chrome all use the same source + +--- + +#### BUG-UI-05 — Broken Customization Preview +**Priority:** P2 | **Area:** Customization / Preview + +**Root Cause Analysis:** +The preview component may show a warning icon instead of the actual component when: +1. The component fails to render +2. The preview uses placeholder data that doesn't match the real component +3. The theme tokens aren't applied to the preview + +**Files Involved:** +- `android/feature-reader/src/main/java/.../ui/ReaderStyleTab.kt` +- `android/feature-reader/src/main/java/.../ui/ReaderStylePresetUiStateMapper.kt` + +**Suggested Fix:** +Ensure the preview component uses the same rendering logic as the actual component. Apply theme tokens consistently. + +--- + +### Additional Bugs from Video + +#### BUG-CANDIDATE-01 — Quote Navigation Broken +**Priority:** P1 | **Area:** Quotes / Document Location / Navigation + +**Root Cause Analysis:** +Quotes are saved with: +```kotlin +data class SavedQuote( + val comicId: String, + val page: Int, // This is the ONLY position data + val text: String, + ... +) +``` + +When navigating to a quote: +```kotlin +onClick = { onQuoteClick(quote.comicId, quote.page) } +``` + +The issue is that `page` is a raw integer that may not correspond to the actual document location: +- EPUB pages change as font/layout changes +- The page number may be from a different reading mode +- No anchor/offset data is stored + +**Files Involved:** +- `android/core-data/src/main/java/.../db/entity/SavedQuote.kt` +- `android/core-data/src/main/java/.../repository/QuoteRepository.kt` +- `android/feature-library/src/main/java/.../LibraryScreenContent.kt` + +**Suggested Fix:** +Store structured location with quotes: +1. Add `positionJson` field (using ReaderPositionCodec) +2. Add `characterOffset` for text-based relocation +3. Add `domAnchor` for fragment-based navigation +4. Use page number only as legacy fallback + +--- + +#### BUG-CANDIDATE-02 — CBR Displayed as RAR +**Priority:** P2 | **Area:** Format Detection / Library Metadata + +**Root Cause Analysis:** +The format detection has TWO separate detectors: +1. `FormatDetector` (engine-api): Maps "cbr" → `ComicFormat.CBR` +2. `ComicFormatDetector` (core-data): Maps "cbr" → `ComicFormat.RAR` + +```kotlin +// ComicFormatDetector.kt:83 +"cbr" -> ComicFormat.RAR // BUG: Should be CBR +``` + +This means files imported via different paths get different format labels. + +**Files Involved:** +- `android/engine-api/src/main/java/.../FormatDetector.kt` +- `android/core-data/src/main/java/.../repository/ComicFormatDetector.kt` +- `android/core-data/src/main/java/.../repository/ComicSourceResolver.kt` + +**Suggested Fix:** +Unify format detection: +1. Use `FormatDetector` as the single source of truth +2. Remove duplicate detection logic from `ComicFormatDetector` +3. Ensure CBR files always map to `ComicFormat.CBR` +4. Update UI to show "Comic Book" instead of "RAR" + +--- + +## Summary Table + +| Bug ID | Priority | Status | Root Cause | Fix Complexity | +|--------|----------|--------|------------|----------------| +| BUG-VERTICAL-01 | P1 | Open | Seekbar uses independent state | Medium | +| BUG-PAGED-01 | P2 | Open | Selection starts before gesture classification | Medium | +| BUG-PAGED-02 | P2 | Open | Viewport calculation not centralized | High | +| BUG-PAGED-03 | P1 | Open | Gesture policy doesn't check for footnotes | Medium | +| BUG-READER-01 | P1 | Open | Multiple page count sources | High | +| BUG-READER-02 | P1 | Open | Position mode overrides preference | Low | +| BUG-READER-03 | P0 | Partial | Dedup guard loses position on rapid exit | Medium | +| BUG-READER-04 | P0/P1 | Open | Progress computed differently per component | High | +| BUG-READER-05 | P2 | Open | State coupling between mode and theme | Low | +| BUG-READER-06 | P3 | Open | Title layout constraint issue | Low | +| BUG-READER-07 | P1 | Open | TOC resolution varies by format | High | +| BUG-UI-01 | P2 | Open | Badge design tokens inconsistent | Medium | +| BUG-UI-02 | P2 | Open | Preset not properly restored | Low | +| BUG-UI-04 | P1 | Open | Theme token pipeline fragmented | High | +| BUG-UI-05 | P2 | Open | Preview doesn't match real component | Medium | +| BUG-CANDIDATE-01 | P1 | Open | Quote stores only page number | Medium | +| BUG-CANDIDATE-02 | P2 | Open | Two format detectors disagree | Low | + +--- + +## Recommendations + +### Immediate Actions (P0/P1) +1. **BUG-READER-03:** Verify `forceSavePositionOnClose` covers all exit scenarios +2. **BUG-READER-04:** Create unified `ReadingProgressModel` +3. **BUG-PAGED-03:** Add footnote hit-test before gesture classification +4. **BUG-CANDIDATE-01:** Add structured position to quote storage + +### Short-term (P1/P2) +1. **BUG-READER-01:** Centralize pagination state +2. **BUG-READER-07:** Unify TOC resolution across formats +3. **BUG-UI-04:** Unify theme token pipeline +4. **BUG-CANDIDATE-02:** Fix format detection inconsistency + +### Long-term (P2/P3) +1. **BUG-VERTICAL-01:** Unify seekbar position source +2. **BUG-PAGED-01:** Add selection initiation delay +3. **BUG-PAGED-02:** Centralize viewport calculation +4. **BUG-UI-01:** Standardize badge design tokens + +--- + +*Report generated by codebase analysis on 2026-08-19T18:54:45.537Z* diff --git a/docs/bug-analysis-summary.md b/docs/bug-analysis-summary.md new file mode 100644 index 000000000..f91bc988d --- /dev/null +++ b/docs/bug-analysis-summary.md @@ -0,0 +1,78 @@ + +# Key Findings Summary + +## Critical Issues (P0/P1) + +### 1. Position Persistence (BUG-READER-03) +**Status:** Partially fixed +**Issue:** Dedup guard loses position on rapid exit +**Fix:** `forceSavePositionOnClose` bypasses the check, but may not cover all edge cases +**Recommendation:** Add periodic snapshots and lifecycle-based saves + +### 2. Progress Desync (BUG-READER-04) +**Status:** Open +**Issue:** Chrome, toolbar, file info, and library show different progress values +**Root Cause:** Different calculation methods for EPUB vs raster formats +**Recommendation:** Create unified ReadingProgressModel + +### 3. Footnote Gesture Conflict (BUG-PAGED-03) +**Status:** Open +**Issue:** Footnotes near screen edges get consumed as page turns +**Root Cause:** Gesture policy doesn't check for footnote presence before classifying +**Recommendation:** Add hit-test priority: Footnote → Interactive → Selection → Navigation + +### 4. Quote Navigation (BUG-CANDIDATE-01) +**Status:** Open +**Issue:** Clicking a quote doesn't return to the exact location +**Root Cause:** Only page number is stored, no anchor/offset data +**Recommendation:** Store structured ReaderPosition with quotes + +## Moderate Issues (P2) + +### 5. Text Selection on Swipe (BUG-PAGED-01) +**Issue:** Swipe gestures sometimes trigger text selection +**Root Cause:** Selection starts before gesture is classified as swipe +**Fix:** Add selection initiation delay (300ms lockout) + +### 6. Reading Mode Not Persisted (BUG-READER-02) +**Issue:** Mode resets to default after reopening book +**Root Cause:** Position's mode overrides user preference +**Fix:** Save mode per-book, prioritize over position mode + +### 7. CBR Format Detection (BUG-CANDIDATE-02) +**Issue:** CBR files shown as RAR +**Root Cause:** Two detectors disagree: FormatDetector→CBR, ComicFormatDetector→RAR +**Fix:** Unify to use FormatDetector as single source of truth + +## Architecture Issues + +### State Fragmentation +The reader has multiple state sources: +- `_uiState` (MutableStateFlow) +- `progressController` (persistent state) +- `sectionPageCounts` (dynamic pagination) +- `lastPersistedProgress` (database snapshot) + +These can diverge, causing the desync issues. + +### Missing Centralization +Several calculations are duplicated: +- Viewport geometry (ChromeInsetsPlan, ReaderViewportGeometry, PagedLayoutParams) +- Page count (EpubSectionPageCountStore, EpubProgressCalculator, DeferredPageCountPolicy) +- Theme colors (ReaderColorScheme, MaterialTheme, custom surfaces) + +## Files to Watch + +1. `ReaderProgressController.kt` — Position saving/loading +2. `PagedGesturePolicy.kt` — Gesture classification +3. `ReaderBookOpeningController.kt` — Position restoration +4. `ReaderBottomBar.kt` — Seekbar implementation +5. `ComicFormatDetector.kt` — Format detection + +## Next Steps + +1. Verify BUG-READER-03 fix covers all scenarios +2. Create unified ReadingProgressModel +3. Add footnote hit-test to gesture policy +4. Fix format detection inconsistency +5. Add structured position to quote storage diff --git a/gradlew b/gradlew old mode 100755 new mode 100644