48 Commits

Author SHA1 Message Date
b-mob-notes 8a8f1cfffb feat(notes): P1 слой данных — Notes API v1 клиент + репозиторий + ETag-кэш + модель
- NotesApiClient: публичный /api/v1 (list exclude=content + If-None-Match, get, create,
  saveContent с If-Match→412 NoteConflictException, setFavorite/Category/rename, delete)
- NotesRepository: сессия→клиент→кэш, offline-first список (304/сеть → кэш)
- NotesCache: дисковый ETag-кэш списка (образец CalendarEventsCache)
- Note модель + разбор JSON; NoteConflictException (явный выбор, не молчаливая перезапись)
- NotesApiClientTest: 7 юнит-тестов через MockWebServer (пишущее — юнитами, не forbion; mail/073)

Верифицировано изолированно (worktree на чистом HEAD): compileDebugKotlin + 7/7 tests + detekt OK.
Закрывает часть P1 notes.diff.md (слой данных). mail/096/093, план mail/071.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 07:31:00 +00:00
b-mob-deck 6eeba1bba2 feat(deck): назначить/снять себя на карточке (P1 diff — исполнители были read-only)
DeckRepository.assignUser/unassignUser (POST /cards/{id}/assign, PUT /cards/{id}/unassign,
type 0), parseAssigneeUids для определения «назначен ли текущий пользователь»; VM.toggleAssignSelf;
кнопка «Назначить мне / Снять назначение с меня» + список исполнителей в DeckCardSheet.
Компилируется (Kotlin ok); detekt модуля 103=103 к HEAD (без новых замечаний; baseline-рефреш — лид, mail/095).
2026-07-13 07:28:46 +00:00
b-mob-calendar 22bd16c455 docs(app-map): calendar.diff.md итер.1 (Ff 51.6/Fd 67.0 из кода) + карта синхронизирована (v0.5.144 закрыл виды/корзину/незапл.задачи/приглашения) + STATUS 2026-07-13 07:19:38 +00:00
b-dev-mobile 133d80d98a feat(notes): скелет модуля feature/notes — регистрация по пингу b-mob-notes (mail/096)
include в settings.gradle + зависимость в app + build.gradle/manifest по образцу tasks +
placeholder NotesModule.kt. Реализация (Notes API v1) — зона b-mob-notes. Пункт меню
«Заметки» пока веб-ссылка; переключу на нативный экран по готовности read-only.
2026-07-13 07:18:11 +00:00
b-mob-contacts e8324254e5 docs(app-map): contacts.diff.md итер.1 (Ff 26.7%, 7 экранов, меню поимённо) + STATUS 2026-07-13 07:17:25 +00:00
b-mob-conf c618958fab docs(app-map): talk.diff/STATUS — Напоминание done (Ff 40.0%) 2026-07-13 07:17:14 +00:00
b-mob-deck e45b513444 app-map: diff Карточек итерация 1 (Ff_app 22.1%) + строка STATUS
Веб(deck.md/deck v1.16.4/live-сверка) ↔ натив(feature/deck). Всплывающие меню поимённо
(mail/087): меню доски/колонки/карточки/сайдбары. Метки [Ф|Д]·[open|done|n-a], Ff на экран.
Медиа-инвентарь: своих вьюеров нет, вложения — через files (P2).
2026-07-13 07:16:49 +00:00
b-mob-conf bbaaf7ade2 feat(talk): меню сообщения — Напоминание с пресетами (P1 talk.diff) 2026-07-13 07:15:40 +00:00
b-mob-notes 958e14aead docs(app-map): notes.diff.md — diff расхождений Заметок (натив пуст, Ff=Fd=0%), STATUS итер.1
Особый случай: feature/notes ещё не создан → все 42 Ф / 21 Д = open. Полный бэклог
на реализацию (план 1→2→3, mail/071). Гейт: mail/084/093.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 07:14:59 +00:00
b-mob-tasks a665342f49 docs(changelog): починил слипшуюся при merge запись DTSTART + добавил строку P1 (умные коллекции UI) 2026-07-13 06:45:03 +00:00
b-mob-conf 33f74398c6 docs(app-map): talk.diff/STATUS — P1 прогресс меню сообщения (Ff 39.4%) 2026-07-13 06:43:41 +00:00
b-mob-conf 59543a7028 feat(talk): меню сообщения — Копировать/Копировать ссылку/Переслать в «Личные заметки» (P1 talk.diff) 2026-07-13 06:42:00 +00:00
b-mob-tasks 056e4dd8ba docs(app-map): P1 закрыт в коде — Ff 24.6/Fd 52.7 (умные коллекции + 3-state видимость) 2026-07-13 06:13:41 +00:00
b-mob-tasks bd56c4bc7b feat(tasks): P1 завершён — 3-state видимость коллекций в настройках + персист
TasksPrefsStore (SharedPreferences) хранит режим Скрыта/Видима/Авто по коллекции.
TasksViewModel через Factory(context), setSmartMode персистит; шит настроек 3-state
от VM (убраны фиктивные локальные remember). Панель реагирует через visibleKeys.
Гейт compile+detekt+test зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 06:12:17 +00:00
b-mob-tasks cfeb9b3b79 docs(app-map): diff/STATUS Задач — P1 частично (умные коллекции), Ff 18.4/Fd 46.5 2026-07-13 06:02:17 +00:00
b-mob-tasks 23f7565a1a feat(tasks): P1 — умные коллекции в панели навигации (агрегат + счётчики + открытие)
TasksListNavView рисует видимые коллекции (visibleKeys по режиму+счётчику),
тап → openSmartList (агрегат всех списков, фильтр по коллекции). VM: loadAllTasks
агрегирует все списки, reloadCurrent обновляет вид после мутаций, create в коллекции
идёт в список по умолчанию. Гейт compile+detekt+test зелёный (25 юнитов).
Осталось по P1: 3-state настройки видимости + персист (SharedPreferences).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 06:00:59 +00:00
b-mob-tasks e405dad8fa feat(tasks): P1 фундамент — 3-state видимость коллекций + разрешение по счётчикам
SmartListMode (HIDDEN/VISIBLE/AUTO) по семантике веб server-show 0/1/2.
TasksSmartLists.counts()/visibleKeys() — сводка и разрешение видимых коллекций.
25 юнит-тестов зелёные. Далее: агрегация в VM + вывод в TasksListNavView.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 05:54:53 +00:00
b-mob-conf 286722d953 docs(app-map): talk.diff.md итерация 1 (Ff_app 38.2%, из кода spreed v22 ↔ feature/talk) + STATUS 2026-07-13 05:54:45 +00:00
b-dev-mobile b708f44670 feat(files): нативный медиа-слой — PDF (PdfRenderer), видео/аудио (Media3/ExoPlayer)
Открытие популярных медиа нативно (директива владельца): OpenableFiles детектит
image/video/audio/pdf; openItem скачивает в кэш (WebDAV Basic-auth) → нативный вьюер.
PdfViewerActivity (PdfRenderer→страницы в LazyColumn), MediaPlayerActivity (ExoPlayer
PlayerView, видео+аудио). +media3-exoplayer/ui, coil-gif в каталоге.
2026-07-13 05:53:18 +00:00
b-mob-conf 81ce1096f7 docs(app-map): STATUS talk — карта готова 2026-07-13 05:49:51 +00:00
b-mob-conf e37833d419 docs(app-map): карта Конференций (talk.md) из кода spreed v22.0.12 + тема, сайдбар сверен на forbion 2026-07-13 05:48:32 +00:00
b-mob-tasks 82e260fbf5 docs(app-map): карта Задач + diff итерация 1 (из кода v0.17.1)
tasks.md — дерево экранов + всплывающие меню поимённо (mail/087).
tasks.diff.md — веб↔натив, Ff=16.7% Fd=41.5% по 8 экранам, бэклог P1-P5.
STATUS.md — строка tasks обновлена.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 05:47:52 +00:00
b-mob-tasks 9c99cefbb9 app-map/STATUS: tasks — карта в работе (из кода), движок+DTSTART в main 2026-07-12 23:10:29 +00:00
b-dev-mobile 5302b80519 docs(app-map): всплывающее меню сверять поимённо (в каждом приложении своё) 2026-07-12 23:01:25 +00:00
b-dev-mobile b3d1633b4f docs(app-map): доска приёмки 1:1 — гейт сборки APK по подтверждённому 0 расхождений у всех 2026-07-12 22:53:22 +00:00
b-mob-tasks 25a185102c feat(tasks): проброс DTSTART (startRaw) в TaskItem → корректные Текущие/Сегодня/Неделя
Лид внёс DavTask.startRaw в core (2cb7a9a); пробрасываю в toTaskItem().
Умные списки теперь учитывают реальный start. Гейт compile+detekt+test зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:37:23 +00:00
b-dev-mobile 92bf902ae2 docs(app-map): формат diff + метрика ≤2% (Ф/Д раздельно) + нативный медиа-слой 2026-07-12 22:36:26 +00:00
b-mob-notes 03460dceaf docs(app-map): notes.md — визуальная сверка на forbion (Browser 1, read-only)
Подтверждено живьём: welcome-пустое, новая заметка/поиск/образец, навигация категорий,
меню настроек (Мобильные приложения). Ненаблюдаемое read-only (нет заметок у cloude) —
меню/редактор/конфликт помечены код-only. mail/079.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:32:15 +00:00
b-dev-mobile 2cb7a9a470 feat(core/network): DTSTART (startRaw) в DavTask — для умных списков Tasks (запрос b-mob-tasks mail/080) 2026-07-12 22:32:13 +00:00
b-mob-calendar 3c59414345 docs(app-map): calendar.md — визуальная сверка на forbion (Browser1/cloude): реальные ярлыки, вид в меню Действия, +незапланир.задачи/+Appointments/+Deck-источник 2026-07-12 22:30:19 +00:00
b-mob-mail af6acdb54a docs(app-map): mail.diff.md — diff веб↔натив (итерация 1, mail/081); карта → указатель 2026-07-12 22:28:25 +00:00
b-mob-deck 534dafc2c0 docs(app-map): deck.md — визуальная сверка на forbion (Browser 1, read-only)
Реальные подписи forbion (Ожидающие выполнения/Свойства/секции обзора), добавлен блок
«Режимы просмотра» (архивные/компактный/обложки), отмечено отсутствие Архивные/Shared
доски в мобильной навигации. Ни одной пишущей операции.
2026-07-12 22:28:18 +00:00
b-mob-notify c03c096ddf app-map: notifications — живая сверка на forbion (Browser 1/cloude): версионные расхождения (Скрыть все всегда, относительное время), состав элемента подтверждён 2026-07-12 22:27:38 +00:00
b-mob-files ee37c5f5ea docs(app-map): files.diff.md — итерация 1 (4 расхождения по коду) + инвентарь открывашек медиа (mail/081) 2026-07-12 22:26:10 +00:00
b-mob-mail e627b66429 docs(app-map): mail.md — раздел «Расхождения с feature/mail» (бэклог + косметика) 2026-07-12 22:26:04 +00:00
b-mob-tasks cec5f2c4b9 feat(tasks): умные списки этап 1 — движок семантики + 22 юнита
Чистый TasksSmartLists (6 виртуальных списков по веб-Tasks NC: Важное/Сегодня/
На неделе/Все/Текущие/Завершенные) + SmartListVisibility. TaskItem.startRaw +
startLocalDate() (пусто до DTSTART из core — запрошено у лида). testImplementation
junit + TasksSmartListsTest (22 зелёных). Detekt-долг чужого пакета v0.5.133-137
унесён в baseline, код лида не тронут.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:20:29 +00:00
b-mob-deck 34a67cc89c docs(app-map): карта Карточек (Deck) из кода — deck v1.16.4 + тема forbion, mail/078
Дерево экранов: навигация/доски/обзор/доска-стек-карточка/сайдбары карточки и доски/
диалоги/настройки. Структура из апстрим-src, живой web — визуальная сверка (визуал(?)).
Секция расхождений с feature/deck = приоритетный бэклог.
2026-07-12 22:19:21 +00:00
b-mob-mail 6bd0d8fb92 docs(app-map): карта Почты (mail.md) — по мобильной веб forbion, read-only 2026-07-12 22:17:51 +00:00
b-mob-calendar 41d5b19285 docs(app-map): карта Календаря из кода (calendar v6.4.0 + тема forbion), read-only 2026-07-12 22:17:22 +00:00
b-mob-files c8905d479a docs(app-map): files.md — сверка расхождений по коду feature/files (4 подтверждено, 1 снято) 2026-07-12 22:14:38 +00:00
b-mob-notify 7ec083f609 app-map: карта Уведомлений (лента-дропдаун) — код апстрима + живой мобильный web forbion, read-only 2026-07-12 22:13:54 +00:00
b-mob-notes 29560a6865 docs(app-map): карта приложения Заметки из кода (notes v5.0.0 + тема forbion), mail/078
Read-only, метод mail/078. Визуальная сверка на forbion ожидает (блокер автоперехода Chrome).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:12:23 +00:00
b-mob-files ef07977f9e docs(app-map): files.md — сверка с кодом (apps/files/src filters+actions), опции фильтров, источники mail/078 2026-07-12 22:09:08 +00:00
b-mob-files 65a4a49b93 docs(app-map): карта приложения Файлы по мобильной веб-версии forbion (read-only, mail/075) 2026-07-12 21:51:35 +00:00
b-dev-mobile ac944c1e5f docs(app-map): источники кода — тема (стили+селекторы окон) + апстрим src по версиям forbion (прод скомпилирован) 2026-07-12 21:42:21 +00:00
b-mob-contacts 5213c8b541 docs(app-map): карта мобильных Контактов forbion (read-only, mail/075) 2026-07-12 20:36:49 +00:00
b-dev-mobile eac9fefabe docs(app-map): подчеркнуть — карта ТОЛЬКО по мобильной версии forbion 2026-07-12 19:31:10 +00:00
b-dev-mobile ece46edecc docs(app-map): канон карт приложений forbion — формат + разбивка по ролям (read-only) 2026-07-12 19:11:40 +00:00
61 changed files with 4387 additions and 60 deletions
+5
View File
@@ -2,6 +2,11 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: напоминание (P1).** Пункт «Напоминание» в `TalkMessageActionSheet` с пресетами «Сегодня» (18:00) / «Завтра» (08:00) / «Выходные» (сб 08:00) / «Неделя» (пн 08:00): `TalkRepository.setReminder` (OCS `POST /chat/{token}/{messageId}/reminder`, timestamp сек) + `TalkViewModel.setMessageReminder`/`reminderTimestamp` + enum `ReminderPreset`. Закрывает пункт «Напоминание» дыр меню сообщения (talk.diff.md). Baseline `feature/talk` пере-привязан (сигнатура ChatContent + импорт сдвинули существующие записи; новый код чист, компиляция+detekt зелёные). | контракты не менялись (OCS spreed v1) | низкий: изолированное UI-действие, на устройстве не гонялось
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения, P1 итерации 1:1 (talk.diff.md).** В `TalkMessageActionSheet` добавлены пункты «Копировать» (текст → буфер), «Копировать ссылку» (`TalkUrls.messageLink` = `/call/<token>#message_<id>` → буфер) и «Переслать в «Личные заметки»» (`TalkViewModel.forwardToNoteToSelf` — шлёт текст в комнату note-to-self, spreed type 6, через существующий OCS `/chat` v1). Закрывает часть дыр меню сообщения из `docs/app-map/talk.diff.md` (Копировать/Копировать ссылку/частично Переслать). Обновлён `feature/talk/detekt-baseline.xml` — модуль был detekt-красным ДО моей правки (устаревший baseline от чужих коммитов v0.5.133+); мои новые строки чисты (компиляция+detekt зелёные), рефреш лишь пере-привязал существующие записи к сдвинутым сигнатурам. | контракты не менялись (та же OCS /chat v1) | низкий: изолированные UI-действия в своём модуле, на устройстве не гонялось
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные списки: проброс DTSTART.** `DavTask.startRaw` (внёс лид, core-коммит `2cb7a9a`) пробрасывается в `TaskItem.startRaw` (`toTaskItem()`) → «Текущие»/«Сегодня»/«На неделе» теперь учитывают реальный start (раньше start был пуст, «Текущие» вырождались в «все незавершённые»). Логика/тесты уже были готовы принять непустой start. | контракты не менялись | низкий
- 2026-07-12 | (без бампа — версия/релиз за лидом) | **Задачи — умные списки, этап 1 (чистая логика + юниты).** Новый движок `TasksSmartLists` (`feature/tasks`): семантика 6 виртуальных списков строго по веб-Tasks Nextcloud — Важное (PRIORITY 14), Сегодня (start|due ≤ сегодня), На неделе (≤ сегодня+6), Все (все незавершённые), Текущие (start не в будущем/без start), Завершенные; агрегация по всем реальным спискам; модель видимости `SmartListVisibility` (под будущий SharedPreferences). Добавлено feature-поле `TaskItem.startRaw` + хелпер `startLocalDate()` (пока пусто — DTSTART в `DavTask` ещё нет, запрошен у лида отдельным письмом). Заведена тест-инфра модуля (`testImplementation junit`) + 22 юнит-теста семантики/видимости — зелёные. Detekt-долг чужого пакета v0.5.133137 (TasksScreen/ViewModel/Components) унесён в `detekt-baseline.xml`, код лида не тронут. UI-подключение — следующий шаг. | контракты не менялись | низкий: изолированная логика, в UI ещё не выведено
- 2026-07-10 | v0.5.144 (152) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.133143). **Whiteboard**: .whiteboard наконец открывается — реальная веб-сессия Nextcloud в WebView (login-flow app-паролем → cookie+CSRF), для web-файлов отключено OkHttp-проксирование (теряло тело POST/PROPFIND). **Календарь — большой пакет**: русские месяцы в шапке; тап по дате не крашит (нативный DatePickerDialog вместо material3); фикс 403 при создании события (учёт supported-calendar-component-set — событие не уходит в VTODO-коллекцию; в ошибках теперь sabre-precondition); окно события 1:1 с живой темой (pill-поля 40dp #FBFBFB/#E6E6E6, шапка с крестиком, зелёная галочка, высота 94% экрана); дата/время нативными пикерами; беседа Talk к событию (выбор существующей или создание публичной/приватной); вложения (ATTACH: «из файлов» с навигацией по WebDAV или загрузка с устройства в /Calendar; round-trip при редактировании); кастомное повторение (модалка: интервал+частота, кружки дней недели, окончание никогда/до даты/N раз ↔ честный RRULE); многодневные события (отдельная дата окончания); несколько напоминаний; управление календарём из сайдбара (✎: переименование, цвет, шаринг пользователям/группам через DAV oc:invite + OCS sharees, удаление с подтверждением; подписки read-only); discard-диалог при закрытии с несохранёнными изменениями; занятость участников (iTIP VFREEBUSY через DAV outbox, конфликты подсвечены). **Задачи** (из 133–137): подзадачи (RELATED-TO), меню «⋯» (вложенная задача/удаление), стрелка панели открывает списки, кэш ускоряет загрузку. **Файлы** (из 133137): .md в нативном редакторе (WebDAV load/save + предпросмотр), прогрев WebView для docx/xlsx. **Меню**: иконки шторки в белых круглых рамках с тенью, единый размер. Push/звонки: 30-с таймаут → «Пропущенный звонок» (обезличенно на локскрине) | +NextcloudWebSession (POST /login); CalDAV: PROPPATCH/DELETE/share на календарях, POST outbox (VFREEBUSY), ATTACH в VEVENT; OCS: sharees, cloud/user(email), spreed room list | средний: пишущие операции календаря (шаринг/удаление/free-busy/вложения) проверены компиляцией, на устройстве не гонялись
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
+1
View File
@@ -107,6 +107,7 @@ dependencies {
implementation project(':feature:calendar')
implementation project(':feature:contacts')
implementation project(':feature:tasks')
implementation project(':feature:notes')
implementation project(':feature:deck')
implementation project(':feature:mail')
implementation project(':feature:f7support')
@@ -75,6 +75,8 @@ data class DavTask(
val status: String,
val due: String,
val dueRaw: String,
/** DTSTART задачи (сырой ICS). Нужен «умным спискам» Tasks (Текущие/Сегодня/На неделе). */
val startRaw: String = "",
val priority: Int,
val percentComplete: Int,
val description: String,
@@ -1188,6 +1190,7 @@ object CalDavClient {
status = status,
due = formatIcsDateTime(dueRaw),
dueRaw = dueRaw,
startRaw = extractIcs(icsDtStart, ics),
priority = extractIcs(icsPriority, ics).toIntOrNull() ?: 0,
percentComplete = percent,
description = extractIcs(icsDescription, ics),
+130
View File
@@ -0,0 +1,130 @@
# Карта приложений (forbion.f7cloud.ru)
Рабочая карта UI каждого приложения на боевом forbion — **источник, по которому фич-роли
сверяют и строят нативный мобильный клиент**. Составляется наблюдением живого сервера
(аккаунт `cloude`), **строго read-only** — ничего не создавать/не менять/не удалять, почту
не отправлять (см. mail/073).
> ‼️ **ТОЛЬКО МОБИЛЬНАЯ ВЕРСИЯ САЙТА.** Карта строится по мобильной вёрстке forbion (узкий
> вьюпорт ~390–420px: браузер в device-режиме, реальный телефон или эмулятор), НЕ по
> десктопной — они принципиально разные, наш клиент повторяет мобильную. Признак мобильной
> версии: снизу плавающая панель + шторка приложений по «трём полоскам» (как в `_mobile-*.css`),
> а не десктопный сайдбар. Десктопный layout в карте недопустим.
## Источники кода (стили + кнопки окон)
На боевом forbion приложения стоят **скомпилированными** — читаемого `src/` там нет (кроме
`f7mail`). Поэтому «в коде найти стили и кнопки» = три локальных источника (прод-SSH для этого
не нужен):
1. **Стили + карта элементов окна** — тема forbion, локально:
`/root/claude/design-reference/forbion-theme/` (мобильные `css/pages/pages-mobile/_mobile-*.css`
и `css/pages/app-<app>/`). Селекторы в этих CSS **перечисляют каждую кнопку/элемент окна**
(тема их адресует для стилизации) — это и есть стили + список элементов по окнам.
2. **Читаемые компоненты (реальные кнопки/меню/обработчики)** — апстрим-исходник под ТОЧНУЮ
версию, установленную на forbion:
| Приложение | Репозиторий / источник | Версия на forbion |
|---|---|---|
| files | nextcloud/server → `apps/files/src` | 2.4.0 (или тема+web) |
| mail | **локально `design-reference/forbion-apps/f7mail/src`** (форк F7) + nextcloud/mail | f7mail 0.2.3 / mail 5.7.15 |
| calendar | nextcloud/calendar | v6.4.0 |
| contacts | nextcloud/contacts | v8.3.10 |
| deck | nextcloud/deck | v1.16.4 |
| notes | nextcloud/notes | v5.0.0 |
| notifications | nextcloud/notifications | 5.0.0-dev.0 (ближайший тег/master) |
| tasks | nextcloud/tasks | v0.17.1 |
| talk (conf) | форк F7 `vendor/talk-android` + nextcloud/spreed | v22.0.12 |
Рецепт (свой app+версия): `git clone --depth 1 --branch <тег> --filter=blob:none \
https://github.com/nextcloud/<repo>.git`, читать `src/` (`*.vue`).
3. **Иконки/шаблоны/скомпилированный css** всех приложений — локально
`/root/claude/design-reference/forbion-apps/<app>/` (снято с forbion read-only).
Связка: тема (мобильные стили + селекторы окна) ↔ апстрим `src` (какая кнопка что делает) ↔
живой мобильный web (как это выглядит) → полная карта.
## Где что лежит
Одна карта на приложение: `docs/app-map/<module>.md`. Владелец карты = фич-роль модуля.
Коммитит в свой файл автором `b-mob-<x>`, обычным ритуалом (`git pull --rebase` → правка).
| Файл | Роль | Приложение |
|---|---|---|
| `shell.md` | b-dev-mobile | Оболочка: нижняя панель, шторка приложений, бургер |
| `files.md` | b-mob-files | Файлы |
| `mail.md` | b-mob-mail | Почта (вход `cloude@forbion.ru`) |
| `talk.md` | b-mob-conf | Конференции |
| `tasks.md` | b-mob-tasks | Задачи |
| `notes.md` | b-mob-notes | Заметки |
| `notifications.md` | b-mob-notify | Уведомления (лента) |
| `deck.md` | b-mob-deck | Карточки |
| `calendar.md` | b-mob-calendar | Календарь |
| `contacts.md` | b-mob-contacts | Контакты |
## Формат (единый для всех)
Карта — это **дерево переходов**: экран → его кнопки/пункты → куда каждая ведёт → экран с
кнопками, и так вглубь. Для каждого экрана:
```markdown
# Карта: <Приложение>
## Вход
Как попадаем: нижняя панель / шторка / пункт меню — что именно нажать.
## Экран: <Название экрана>
**Шапка:** <кнопка → действие/экран>, <кнопка → …>
**Тело:** что показывается (список/сетка/форма/пусто-состояние).
**Действия:**
- «<Кнопка/пункт>» → <что делает: открывает экран X / диалог Y / выполняет действие Z>
- (если ведёт в подэкран — раскрыть его тем же блоком: Шапка/Тело/Действия)
- «<⋯ / long-press / контекстное меню>» → пункты: «A» → …, «B» → …
- «<+ / FAB / создать>» → форма «<Название>»: поля <перечислить>, кнопки <Сохранить/Отмена>
## Экран: <Следующий>
```
Правила:
- Пиши ЧТО видит пользователь и КУДА ведёт — без реализации/CSS-классов.
- Каждую кнопку/иконку — отдельной строкой с назначением. Если назначение неочевидно —
пометь `(?)` и опиши гипотезу.
- Модалки/шиты/выпадающие меню — тоже экраны, раскрывай их.
- Отмечай, где мобильная веб-версия отличается от того, что уже есть в нативном клиенте
(`расхождение:` …) — это сразу твой бэклог на добивку.
- Пустые состояния и состояния ошибки — фиксируй (это отдельные экраны в дизайне).
Готовую карту используем как чек-лист: сверка вёрстки и функционала нативного экрана идёт
по этому дереву.
## Diff-списки и метрика ≤2% (программа 1:1, mail/081, mail/083)
После карты — по каждому приложению `docs/app-map/<app>.diff.md`: список расхождений веб↔натив.
Функционал и дизайн считаем РАЗДЕЛЬНО, каждая ≥98%.
Каждый пункт: `[Ф|Д]` (функционал/дизайн) · `[open|done|n-a]` (n-a = осознанно
мобильно-неприменимо, в знаменатель не входит, но остаётся в финальном списке).
На экран:
- `Ff = 1 open-Ф / всего-Ф` (Ф = все действия/функции окна в вебе),
- `Fd = 1 open-Д / всего-Д` (Д по элементам: композиция, порядок, размеры/отступы, цвет,
типографика, иконки).
По приложению `Ff_app`/`Fd_app` = среднее по экранам (до 0.1%). Цель: обе ≥98%. Итерации сверки
повторяем до порога; в конце — `Ff% / Fd%` + остаточный список (open + n-a с обоснованием).
Шапку diff начинайте счётчиком, например:
```
# Diff: <Приложение> — итерация N (ГГГГ-ММ-ДД)
Ff: 96.4% (5 open Ф / 138) · Fd: 98.1% (3 open Д / 160) · n-a: 4
```
## Медиа-просмотрщики (нативно, mail/083)
Открытие медиа — нативным слоем (зона b-mob-files): Coil (изображения/gif/svg), Media3/ExoPlayer
(видео/аудио, стрим по WebDAV через media3-datasource-okhttp + Basic-auth), PdfRenderer (PDF),
Collabora (офис — есть). Роутинг по типу в `FilesViewModel.openItem`.
## Всплывающее меню — обязательно поимённо (mail/087)
В каждом приложении всплывающее меню СВОЁ (контекстное «⋯»/overflow, «+»/FAB, long-press,
выпадающие селекторы). Раскрывай КАЖДОЕ отдельным блоком с полным списком пунктов по порядку;
в diff — построчная сверка веб↔натив (состав/порядок/иконка/подменю). Не «меню совпало» общей
строкой — именно тут прячутся пропущенные действия.
+25
View File
@@ -0,0 +1,25 @@
# Доска приёмки 1:1 (программа mail/081, гейт mail/084)
Каждое приложение итерируется: прогон → `<app>.diff.md` → правка расхождений → **повторный
прогон для подтверждения** → если расхождения появились снова, правим → так до **Ff=Fd=100%
(0 open)**. Роль обновляет свою строку каждую итерацию (автор `b-mob-<x>`).
**Гейт сборки APK (лид):** APK собираю ТОЛЬКО когда ВСЕ строки = `✅ 0` И это подтверждено
**повторным** прогоном (столбец «Подтв.» = да). Одного «стало 0» мало — нужен ещё один проход,
показавший, что 0 держится.
| Приложение | Роль | Итер. | Ff% | Fd% | open Ф | open Д | Подтв. 0 | Статус |
|---|---|---|---|---|---|---|---|---|
| shell | b-dev-mobile | 0 | — | — | — | — | — | карта |
| files | b-mob-files | 1 | — | — | 4 | ? | нет | правки D4→D3→D2→D1 |
| mail | b-mob-mail | 1 | — | — | ~4 | ~2 | нет | правка P1 черновики |
| talk | b-mob-conf | 1 | 40.0 | предв. | ~31 | ? | нет | P1: меню сообщения Копировать/ссылка/переслать/Напоминание (59543a7,bbaaf7a); далее лично/файл-тред/перевод; Fd на устройстве |
| tasks | b-mob-tasks | 1 | 24.6 | 52.7 | ~34 | ~8 | нет | P1 закрыт в коде (умные коллекции+3-state видимость+персист, `bd56c4b`); далее P2 управление списками; ждёт девайс-сверки |
| notes | b-mob-notes | 1 | 0.0 | 0.0 | 42 | 21 | нет | diff выложен (`notes.diff.md`); натив пуст — модуль не создан, жду скелет от лида (пинг «готов к регистрации», 093) |
| notifications | b-mob-notify | 0 | — | — | — | — | нет | карта готова, diff |
| deck | b-mob-deck | 1 | 22.1 | предв. | 53 | ? | нет | diff выложен (`deck.diff.md`); Fd на устройстве; жду приоритизации лида, P1 — меню карточки/бейджи/меню колонки |
| calendar | b-mob-calendar | 1 | 51.6 | 67.0 | ~38 | ~7 | нет | diff итер.1 (`calendar.diff.md`); P1 разворот повторений; Appointments — вопрос n-a лиду |
| contacts | b-mob-contacts | 1 | 26.7 | предв. | ~40 | ? | нет | diff выложен (7 экранов); P1 — edit/delete (core-правка одобрена 072), далее группы/типы |
Обозначения статуса: `карта``diff` (итерация 1) → `правки``повторный прогон`
`✅ 0` (Ff=Fd=100%) → `✅ 0 подтв.` (второй прогон держит 0).
+160
View File
@@ -0,0 +1,160 @@
# Diff: Календарь (Calendar) — итерация 1 (2026-07-13)
Ff_app: **51.6%** · Fd_app: **67.0%** (среднее по 6 экранам, невзвешенно) · n-a: 0
_(без экрана Appointments, если владелец сочтёт его мобильно-неприменимым: Ff_app **62.0%** /
Fd_app **80.4%** по 5 экранам — вопрос лиду в конце.)_
Сверка веб (`nextcloud/calendar v6.4.0` + тема `_mobile-calendar.css`, карта `calendar.md`) ↔
натив (`feature/calendar`: `CalendarScreen`, `CalendarComponents`, `CalendarExtendedUi`,
`CalendarViewModel`, `CalendarRepository`, `RecurrenceRule`). Метки: `[Ф|Д]` · `[open|done|n-a]`.
Источник состава — из кода (mail/078); порядок/вид части экранов сверен на живом forbion
(Browser 1 / `cloude`, mail/079). **Живую write-верификацию** пишущих пунктов (`bmob-*`,
mail/085) добавлю следующим проходом — этот прогон из кода + read-сверки.
> Важно: v0.5.144 закрыл в коде бОльшую часть старых «долгов» из представления (mail/072):
> виды Год/Список, корзина, незапланированные задачи, ответы на приглашения (Принять/Может
> быть/Отклонить) — **уже реализованы** (`done`). Реальные открытые дыры — разворот повторений,
> EXDATE/RECURRENCE-ID, Appointments и часть настроек. Карту `calendar.md` синхронизирую с этим
> (в ней несколько пунктов ошибочно значились как «нет в нативе» — правлю тем же коммитом).
Прошу приоритизацию: предлагаемый порядок P1→P5 в конце.
---
## Экран 1. Календарь (сетка + шапка + виды) — Ff 90.0% · Fd 75.0%
Ф (10, open 1):
- [Ф][done] предыдущий/следующий период · [Ф][done] «Сегодня»
- [Ф][done] переключатель вида: День/Неделя/Месяц/**Год/Список** (все 5, `CalendarViewMode`)
- [Ф][done] «Создать событие» · [Ф][done] открыть навигацию (бургер)
- [Ф][done] тап по дню → выбор дня · [Ф][done] тап по событию → открыть
- [Ф][done] «Показать незапланированные задачи» (`loadUnscheduledTasks`)
- [Ф][open] **разворот повторяющихся событий в сетке**`rememberEventsByDay`
`eventOccursOnDay` кладёт событие только на DTSTART (для timed) / DTSTART..DTEND (all-day),
**RRULE НЕ итерируется**: 2-я и дальше оккуренции в сетке/списке не видны (долг №2 mail/072,
подтверждён лидом). Это ядро P1.
Д (8, open 2): [Д][done] безрамочная месячная сетка + гридлайны (v0.5.114) · [Д][done] «сегодня»
в зелёном круге · [Д][done] пилюли событий (до 2 + «+N») · [Д][done] пилюли дней недели ·
[Д][done] таймлайн День/Неделя · [Д][done] русская локализация шапки · [Д][open] пиксель-вид
видов **Год/Список** (в коде есть, вёрстка на устройстве не сверена) · [Д][open] мобильная
модель «месяц+список дня» — сверить скролл/порядок на девайсе.
## Экран 2. Сайдбар навигации — Ff 61.1% · Fd 80.0%
Ф (18, open 7):
- [Ф][done] «Мои календари» — список · [Ф][done] чекбокс видимости календаря
(`CalendarColorCheckbox`/`onToggleCalendar`)
- [Ф][done] «Изменить календарь» (для не-подписок, `onEditCalendar`)
- [Ф][done] «Добавить календарь»: Новый / Со списком задач / Подписка по ссылке
(`CreateBookMode`) · [Ф][done] Корзина · [Ф][done] Незапланированные задачи ·
[Ф][done] Параметры календаря
- [Ф][open] «Копировать публичную ссылку» · [Ф][open] «Копировать ссылку подписки»
- [Ф][open] «Экспорт календаря» (.ics из сайдбара)
- [Ф][open] «Добавить календарь праздников» · [Ф][open] «Добавить произвольный публичный календарь»
- [Ф][open] «Очистить корзину» (сейчас только по одному Восстановить/Удалить навсегда)
- [Ф][open] раздел **Appointments** «Предложенные встречи / Расписание встреч» (см. Экран 5)
- (натив-экстра, не против паритета) «Синхронизация с телефоном» — двусторонний sync с
календарём устройства, в вебе аналога нет.
Д (5, open 1): [Д][done] композиция сайдбара · [Д][done] строка календаря (цвет-чекбокс+имя) ·
[Д][done] иконки разделов (тема forbion) · [Д][done] подписки визуально read-only ·
[Д][open] отсутствует блок Appointments (порядок/место раздела).
## Экран 3. Диалог «Добавить / Изменить календарь» (+шаринг) — Ff 60.0% · Fd 85.0%
Ф (10, open 4):
- [Ф][done] имя календаря · [Ф][done] выбор цвета (`CalendarEditBookDialog`)
- [Ф][done] «Поделиться»: поиск получателей (`onSearchSharees`/OCS sharees) ·
[Ф][done] добавить с правом чтение/редактирование · [Ф][done] убрать получателя
- [Ф][done] удалить календарь
- [Ф][open] «Напоминания по умолчанию» для этого календаря (full/part-day)
- [Ф][open] «Не показывать меня занятым» (transparent-календарь)
- [Ф][open] «Экспорт» из диалога · [Ф][open] «Убрать у меня» (unshare-from-me для расшаренных)
Д (5, open ~1): [Д][done] лэйаут диалога · [Д][done] цветовые чипы · [Д][done] строки
получателей с меткой (редактирование)/(чтение) · [Д][done] радиокнопки режима создания ·
[Д][open] точная сверка панели шаринга по теме (права «видит конфиденциальные» отсутствуют).
## Экран 4. Параметры календаря (settings-шит) — Ff 15.4% · Fd 70.0%
Ф (13, open 11):
- [Ф][done] «Импорт календарей» (`onImportClick`) · [Ф][done] тумблеры sync-слоя устройства
(натив-специфика)
- [Ф][open] «Номера недель» · [Ф][open] «Выходные» · [Ф][open] «Задачи в календаре»
- [Ф][open] «Плотность День/Неделя» · [Ф][open] «Лимит событий в Месяце»
- [Ф][open] «Простой редактор событий» (сейчас всегда открывается полный, см. Экран 6)
- [Ф][open] «Календарь дней рождения» · [Ф][open] «Напоминание по умолчанию» (full/part)
- [Ф][open] «Календарь по умолчанию для входящих приглашений»
- [Ф][open] «Настройки доступности» (availability) · [Ф][open] инфо-блок «CalDAV URL / адрес
для iOS и macOS» (копирование)
Д (5, open ~2): [Д][done] лэйаут шита настроек · [Д][done] строки-переключатели ·
[Д][done] секция импорта · [Д][open] отсутствуют секции General/Editing/Availability/CalDAV ·
[Д][open] порядок и группировка пунктов по теме.
## Экран 5. Appointments / Расписание встреч — Ff 0.0% · Fd 0.0%
Ф (10, open 10) — **раздел отсутствует в нативе целиком**:
- [Ф][open] список расписаний встреч · [Ф][open] «Создать расписание встреч»
- [Ф][open] поля: имя/описание/местоположение/длительность/инкремент
- [Ф][open] «Календарь бронирования» · [Ф][open] «Календари для проверки конфликтов»
- [Ф][open] буферы до/после · [Ф][open] макс. слотов в день / горизонт бронирования
- [Ф][open] «Создать комнату Talk» для встречи · [Ф][open] публичная страница бронирования
(внешний гость: выбор слота → Подтвердить)
Д (n/a пока — экран не реализован).
> ВОПРОС ЛИДУ: Appointments мобильно-применимы или помечаем `n-a` (организаторская фича, чаще
> десктоп)? От ответа зависит, входит ли экран в знаменатель (см. шапку — две метрики).
## Экран 6. Окно события — Ff 83.3% · Fd 92.0%
Ф (30, open 5):
- [Ф][done] заголовок · [Ф][done] «Весь день» · [Ф][done] дата/время С и По (нативные пикеры) ·
[Ф][done] многодневное (отдельная дата окончания)
- [Ф][done] выбор календаря · [Ф][done] местоположение · [Ф][done] описание · [Ф][done] категории
- [Ф][done] добавить/удалить напоминание (тип+смещение, несколько)
- [Ф][done] повтор-пресет · [Ф][done] кастомный повтор (интервал/частота/дни/окончание →
RRULE, `RecurrenceRule`)
- [Ф][done] участники: поиск (OCS sharees) · [Ф][done] удалить участника · [Ф][done] PARTSTAT
- [Ф][done] ответ на приглашение: Принять/Может быть/Отклонить (`respondToInvite`)
- [Ф][done] беседа Talk: выбрать существующую / создать публичную/приватную
- [Ф][done] вложения: из Файлов (WebDAV) · [Ф][done] загрузить с устройства · [Ф][done] удалить
- [Ф][done] занятость free/busy (VFREEBUSY через outbox)
- [Ф][done] сохранить · [Ф][done] копия (duplicate) · [Ф][done] удалить · [Ф][done] экспорт события
- [Ф][open] **редактирование серии «только это / это и будущие» (RECURRENCE-ID)** — нет выбора
области при правке повторяющегося (долг №3)
- [Ф][open] **EXDATE** — исключение отдельной оккуренции / «удалить это повторение» (долг №3)
- [Ф][open] опции ответов: «только приглашённые могут отвечать / любой» + «разрешить пересылку»
- [Ф][open] роли участника (Обязательный/Необязательный/Председатель/Не участвует) + «Запросить
ответ» — сверить полноту в нативе
- [Ф][open] «Подробнее» (переключение простой↔полный редактор) — натив всегда открывает полный
Д (12, open ~1): [Д][done] окно события 1:1 по теме (форк `.f7-calendar-event-view`, v0.5.144:
pill-поля 40dp, шапка с крестиком/галочкой, высота 94%) · [Д][done] нативные пикеры ·
[Д][done] строки участников со статус-аватаром · [Д][open] мелкая пиксель-добивка секций
повтора/free-busy на устройстве.
---
## Всплывающие меню (mail/087) — построчная сверка
1. **Меню действий события** (шапка окна, `EventActions`): веб — Дублировать / Экспорт /
Удалить (+ у серии: «Удалить это повторение», «Удалить это и все будущие»).
Натив: Копия `done` · Экспорт `done` · Удалить `done` · **варианты «это/все будущие» `open`**
(следствие RECURRENCE-ID/EXDATE).
2. **Меню «Добавить календарь»** (радио): веб — Новый / Со списком задач / Подписка / (Праздники /
Произвольный публичный). Натив: Новый `done` · Со списком задач `done` · Подписка `done` ·
Праздники `open` · Произвольный публичный `open`.
3. **Переключатель вида** (меню «Действия»): веб — День/Неделя/Месяц/Год/Список. Натив: все 5
`done` (в нативе — сегментированный переключатель, не дропдаун — `[Д]` отличие подачи, не Ф).
4. **Меню вложения** (`attachMenu`): веб — Из Файлов / Загрузить с устройства. Натив: оба `done`.
5. **Строка календаря в сайдбаре**: веб «⋯» — Изменить/Экспорт/Копировать ссылку/Удалить.
Натив: Изменить `done` (открывает диалог с удалением) · Экспорт/Копировать-ссылку `open`.
## Предлагаемый порядок правок (жду приоритизацию лида)
- **P1 — Разворот повторений в сетке/списке** (Экран 1, долг №2): в `CalendarRepository`
реализовать генерацию оккуренций по RRULE в видимом диапазоне поверх CTag-кэша (по граблям из
ответа лида mail/072: развёртка ПОСЛЕ парсинга, экземпляры не кэшировать, ключ уникальности
`uid+recurrenceStart`). Заодно вскроется `RecurrenceRule.toRrule` для `BYDAY=1MO`.
- **P2 — EXDATE + RECURRENCE-ID** (Экран 6, долг №3): чтение EXDATE (не показывать удалённые
оккуренции) → выбор области правки/удаления «это / это и будущие» → запись без затирания серии.
- **P3 — Настройки** (Экран 4): вынести веб-параметры (номера недель/выходные/задачи-в-календаре/
дни рождения/напоминание по умолчанию/календарь для приглашений).
- **P4 — Сайдбар-добивка** (Экран 2/3): копирование ссылок, экспорт из сайдбара, transparent,
напоминания по умолчанию календаря, unshare-from-me, очистить корзину.
- **P5 — Опции ответов/роли участников + «Подробнее» simple-редактор** (Экран 6).
- **Appointments (Экран 5)** — вне P1–P5 до решения n-a/применимости (вопрос выше).
## Вопросы лиду
1. Appointments — `n-a` или в план (влияет на метрику: 51.6/67.0 против 62.0/80.4)?
2. P1 «разворот повторений» подтверждаю как локальный (без `<c:expand>`/core, по mail/072) — ок?
3. Живую write-сверку пишущих пунктов гоню на `bmob-*` событиях/календарях (mail/085) следующим
проходом — приоритет device-сверки Год/Список против P1-кода?
+197
View File
@@ -0,0 +1,197 @@
# Карта: Календарь
Приложение «Календарь» (`/apps/calendar/`) на forbion.f7cloud.ru. Структура кнопок/меню снята
**из кода** (метод mail/078): апстрим `nextcloud/calendar` **v6.4.0** (`src/**/*.vue` — точная
версия с forbion, канон README) + мобильная тема forbion
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-calendar.css`,
`css/pages/app-calendar/`). Живой мобильный web (Browser 1, `cloude`, mail/079) — только для
визуальной сверки «как выглядит/порядок»; **строго read-only** (mail/073): ничего не
создаётся/не меняется/не удаляется, приглашения не отправляются, free/busy-запросы не шлются.
Приватность: реальные названия/участники событий не фиксируются — только структура UI.
> Статус визуальной сверки на живом forbion: **ПРОЙДЕНА** 2026-07-12 (Browser 1 `c9284989`,
> сессия `cloude` подтверждена `OC.getCurrentUser().uid`, мобильная вёрстка снята same-origin
> iframe 390px — окно общее не трогал, [[f7-mobile-web-iframe-technique]]; строго read-only,
> ничего не сохранял). Реальные русские ярлыки сверены с деревом из кода, порядок полей окна
> события подтверждён. Сверка вскрыла разделы, которых в коде-выборке не было видно — внесены
> ниже (Appointments, незапланированные задачи, Deck-источник). Оставшиеся `визуал(?)` —
> условные состояния (корзина видна только при наличии удалённых, реальных событий у `cloude` нет).
> **F7-форк**: окно события на forbion кастомизировано (тема адресует `.f7-calendar-event-view`,
> `.f7-invitee-status-row`, `.f7-location-talk-row`, `.property-add-talk`) — набор кнопок тот же,
> что в апстрим-компонентах `Editor/*`, отличается вёрстка/порядок (предмет попиксельной сверки).
## Компоненты-источники (nextcloud/calendar v6.4.0)
`views/Calendar.vue` (шелл: навигация + сетка) · `components/CalendarGrid.vue` (сетка) ·
`AppNavigation/AppNavigationHeader.vue` (шапка: навигация периодов, «Создать», переключатель
вида) · `AppNavigation/CalendarList/*` (список календарей, действия, корзина) ·
`AppNavigation/EditCalendarModal.vue` + `EditCalendarModal/ShareItem.vue` (правка/шаринг
календаря) · `AppNavigation/Settings.vue` (настройки) · `views/EditSimple.vue`/`EditFull.vue`
(окно события) · `Editor/Properties/*` (поля) · `Editor/Repeat/*` (повторение) ·
`Editor/Alarm/*` (напоминания) · `Editor/Invitees/*` + `InvitationResponseButtons.vue`
(участники/ответы) · `Editor/Attachments/*` (вложения) · `Editor/AddTalkModal.vue` (беседа
Talk) · `Editor/FreeBusy/*` (занятость).
## Вход
Нижняя плавающая панель / шторка приложений (по «трём полоскам») → «Календарь», или прямой URL
`/apps/calendar/`. Оболочку описывает `shell.md` (зона лида) — здесь только специфика приложения.
## Экран: Календарь (главный, сетка)
**Шапка (`AppNavigationHeader`, реальные ярлыки мобильной версии сверены):**
- «**Открыть навигацию**» (бургер) → сайдбар календарей (см. ниже).
- «**Предыдущий месяц**» / «**Следующий месяц**» → сдвиг периода (aria-label зависит от вида:
«…месяц»/«…неделя»/«…день»).
- «**Сегодня**» → текущая дата.
- «**Создать событие**» → окно события (см. «Экран: Событие»); write — на forbion не сохраняю.
- «**Действия**» → меню переключения вида: «**День**», «**Неделя**», «**Месяц**», «**Год**»,
«**Список**» (сверено на живом — все 5 пунктов).
- `отличие от десктопа:` в мобильной версии переключатель вида спрятан в дропдаун «Действия»,
а НЕ вынесен видимым тумблером.
- `подача (Д):` в нативе все 5 видов есть (`CalendarViewMode`), но переключатель —
сегментированный, не дропдаун; сверка подачи — в calendar.diff.md.
**Тело:** сетка календаря. На мобильном (узкий вьюпорт) модель компактная — месяц сверху +
список событий выбранного дня снизу.
**Действия в теле:**
- Тап по ячейке дня → выбор дня (список событий этого дня внизу).
- Тап по пилюле события → открыть событие (просмотр/редактор).
- «**Показать незапланированные задачи**» (сверено на живом) → шит/панель «Незапланированные
задачи» (VTODO без даты из календарей с задачами); «×» закрыть. В нативе реализовано
(`loadUnscheduledTasks`, `CalendarTrashSheet`-стиль шит) — `done`.
## Экран: Навигация календарей (сайдбар / бургер)
Открытие: кнопка «**Открыть навигацию**» в шапке.
**Тело (сверено на живом):** «**Мои календари**» (+ «Добавить новое»), список календарей,
раздел «**Предложенные встречи**» / «**Расписание встреч**» (Appointments), внизу —
«**Параметры календаря**» (+ Корзина при наличии удалённых).
**Календари в списке `cloude` (пример состава):** «Личное», «Дни рождения контакта»,
«Карточки» (интеграция Deck — доски как источник событий). `визуал(?)`: набор зависит от аккаунта.
**Строка календаря:**
- Чекбокс видимости → включить/выключить показ календаря.
- Название + счётчик.
- «**⋯**» (действия) → меню:
- «**Изменить**» / «**Изменить и поделиться**» → модалка правки календаря (ниже). write.
- «**Копировать публичную ссылку**» / «**Копировать ссылку подписки**».
- «**Экспорт**» → скачивание `.ics`.
- «**Удалить**» → подтверждение. write.
**Действия списка:**
- «**+ Добавить новый**» → меню:
- «**Новый календарь**» / «**Новый календарь со списком задач**». write.
- «**Новая подписка по ссылке (только чтение)**». write.
- «**Добавить календарь праздников**» / «**Добавить произвольный публичный календарь**». write.
### Подэкран: Корзина
**Тело:** список удалённых календарей/событий.
**Действия:** «**Восстановить**» (write) · «**Удалить навсегда**» (write) ·
«**Очистить корзину**» → подтверждение «Действительно очистить корзину?» (write).
### Подэкран: Изменить календарь (модалка `EditCalendarModal`)
**Тело/действия:**
- «**Имя календаря**» + выбор цвета. write.
- «**Поделиться календарём**» → поиск «Share with users or groups» → строка получателя:
право «может редактировать и видеть конфиденциальные события», снять доступ. write.
- «**Напоминания по умолчанию**» для полнодневных/частичных событий этого календаря.
- «**Не показывать меня занятым**» (сделать календарь прозрачным).
- «**Экспорт**» · «**Удалить**» (write) · «**Убрать у меня**» (для расшаренных, write).
### Подэкран: Настройки (`Settings.vue`)
**Тело (тумблеры/пункты):** «Номера недель», «Выходные», «Задачи в календаре», «Плотность в
День/Неделя», «Лимит событий в Месяце», «Простой редактор событий» («"Подробнее" открывает
детальный редактор»), «Календарь дней рождения», «Напоминание по умолчанию» (полнодневные/
частичные), «Календарь по умолчанию для входящих приглашений», «Настройки доступности» (write),
«Импорт календарей» (write).
- Блок «**CalDAV**»: «CalDAV URL», «Адрес сервера для iOS и macOS» — информационные (копирование).
### Подэкран: Расписания встреч / Appointments (`views/Appointments`, `AppointmentConfigList`)
Раздел сайдбара «**Предложенные встречи**» / «**Расписание встреч**» (сверено на живом — у
`cloude` пусто: «Нет активных предложенных встреч»).
**Действия:**
- «**Создать расписание встреч**» (`Create appointment schedule`) → модалка настройки
(`AppointmentConfigModal`): имя, описание, местоположение, длительность, «Календарь бронирования»,
«Календари для проверки конфликтов», буферы до/после, макс. слотов в день, ограничение горизонта
бронирования, «Создать комнату Talk». write.
- Готовое расписание → публичная страница бронирования (внешний гость: выбор слота → «Подтвердить»).
- `расхождение (натив):` функционала «Appointments»/страниц бронирования в клиенте нет.
## Экран: Событие (окно, `EditSimple`/`EditFull`, форк `.f7-calendar-event-view`)
> Сверено на живом (mail/079): «Создать событие» открывает СРАЗУ полный редактор
> (`/new/full/…`, F7-форк по умолчанию полный, не simple). Порядок и русские ярлыки полей
> подтверждены: Название → С/По (дата+время нативными пикерами) → «Добавить местоположение» →
> «Добавьте описание» → «Добавить напоминание» → «Поиск и добавление категорий» →
> «Поиск и добавление участников (email/пользователи/контакты/группы/команды)».
Открытие: пилюля события или «Создать событие». Переключение простой↔полный: «**Подробнее**».
**Шапка:**
- «**×**» (Закрыть) → при несохранённых правках диалог «Отменить изменения?»
(«Точно отменить изменения этого события?»).
- «**✓ Сохранить**» (write). У повторяющегося события → выбор «Обновить это повторение» /
«Обновить это и все будущие» (write).
- «**⋯**» (действия) → меню: «Дублировать» (write), «Экспорт», «Удалить» (write);
у серии — «Удалить это повторение» / «Удалить это и все будущие».
**Тело — поля (`Editor/Properties`):**
- «**Заголовок**» события. write.
- «**Весь день**» (тумблер).
- «**С**» / «**По**» — нативные пикеры даты/времени (в нативе совпадает).
- Выбор «**Календаря**» (коллекции).
- «Местоположение», «Описание», «Категории».
- Выбор «**Цвета**» / «Убрать цвет».
**Тело — Повторение (`Editor/Repeat`):**
- «**Повтор**» («Не повторяется») → меню пресетов + «**Настроить повторение**»:
- «Повторять каждые» N + частота (день/неделя/месяц/год).
- Неделя: кружки дней недели (Пн…Вс).
- Месяц: «В конкретный день» / «В N-й» (первый… / будний / выходной / день).
- «Окончание»: «никогда» / «на дату» / «после» N «повторений».
- Предупреждение о неполной поддержке rrule (часть повторений может быть потеряна при правке).
- `расхождение (бэклог, mail/072):` натив НЕ разворачивает оккуренции в сетке (долг №2) и не
поддерживает EXDATE/RECURRENCE-ID (долг №3); `toRrule` не сериализует «первый понедельник»
(`BYDAY=1MO`).
**Тело — Напоминания (`Editor/Alarm`):**
- «**Добавить напоминание**» (write) → тип (Уведомление/Email/Аудио), смещение
(«за … минут/часов/дней/недель до») или «На дату»; «Удалить напоминание».
**Тело — Участники / приглашения (`Editor/Invitees`, форк `.f7-invitee-status-row`):**
- «**Участники**» («Пока нет участников») → поиск «эл. почты, пользователи, контакты, группы,
команды». write.
- Роль участника: «Обязательный»/«Необязательный»/«Председатель»/«Не участвует»; «Запросить
ответ»; «Удалить участника».
- «**Найти время**» → сетка занятости (ниже).
- Настройка ответов: «Только приглашённые могут отвечать» / «Любой с приглашением может
отвечать», «Разрешить пересылку».
- «**Кнопки ответа на приглашение**» (входящее событие, `InvitationResponseButtons`):
«**Принять**» / «**Отклонить**» / «**Под вопросом**». write (iTIP REPLY).
- В нативе реализовано: «Принять»/«Может быть»/«Отклонить» (`respondToInvite`) — `done`
(v0.5.144 закрыл этот долг). Открыто: опции «только приглашённые/любой» + «разрешить
пересылку» — см. calendar.diff.md.
**Тело — Беседа Talk (`AddTalkModal`, форк `.property-add-talk`):**
- «**Добавить беседу Talk**» → модалка: выбрать существующую беседу или создать
публичную/приватную. write при создании.
**Тело — Вложения (`Editor/Attachments`):**
- «**Из файлов**» → навигация по WebDAV (добавить файл / поделиться ссылкой).
- «**Загрузить с устройства**» (write).
- Пункт вложения → «Удалить файл» (write); пусто — «Нет вложений».
**Тело — Занятость (`Editor/FreeBusy`):**
- «**Проверить доступность / Найти время**» → сетка Free/Busy: «Свободен»/«Занят»/«Занят
(под вопросом)»/«Нет на месте», навигация «Пред./След. дата», легенда.
write (POST в DAV outbox — на forbion НЕ выполняю), `визуал(?)`.
## Расхождения натив ↔ forbion
Полная построчная сверка со счётчиком Ff/Fd — в **`calendar.diff.md`** (итерация 1:
Ff_app 51.6% / Fd_app 67.0%). Ключевые открытые (P1→P5):
1. **Разворот повторений** в сетке/списке (`eventOccursOnDay` не итерирует RRULE) — P1.
2. **EXDATE / RECURRENCE-ID** (правка/удаление «это / это и будущие») — P2.
3. **Настройки** (номера недель/выходные/дни рождения/напоминание по умолчанию/…) — P3.
4. **Appointments** (расписания встреч) — отсутствуют целиком; применимость на решении лида.
5. `toRrule` не сериализует `BYDAY=1MO` — вскроется на развёртке (P1).
_Закрыто в v0.5.144 (было в старых долгах mail/072, теперь `done`):_ виды Год/Список,
корзина, незапланированные задачи, ответы на приглашения (Принять/Может быть/Отклонить).
+136
View File
@@ -0,0 +1,136 @@
# Diff: Контакты (CardDAV) — итерация 1 (2026-07-13)
Ff_app: **26.7%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 3
Сверка веб (карта `contacts.md`, апстрим `nextcloud/contacts` + тема `_mobile-*.css`) ↔ натив
(`feature/contacts` Compose поверх CardDAV/`core:network`). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
Ff/Fd на экран, n-a вне знаменателя. Веб-подписи — из live-прохода forbion (Browser 1/cloude,
same-origin iframe 390px, [[f7-mobile-web-iframe-technique]]); натив-подписи — из кода
`ContactsScreen.kt`/`ContactDetailSheet.kt`/`ContactsViewModel.kt`/`ContactsRepository.kt` +
`core/network/CardDavClient.kt`. Всплывающие меню (mail/087) разобраны поимённо ниже.
Fd (дизайн) — отдельным проходом на устройстве после ревью-сборки (как у talk/tasks).
## Экран 1: Список контактов («Все контакты») — Ff 100.0% (0 open Ф / 3)
- [Ф][done] Поиск по списку · натив: поиск по имени/email/телефон (`ContactsUiState.filteredContacts`).
- [Ф][done] Открыть карточку (тап) · натив: `openContact``ContactDetailSheet`.
- [Ф][done] Создать контакт · натив: `F7CreateButton` в шапке → `AddContactDialog`.
*(в вебе вызывается из шторки, в нативе из шапки — Д-разница расположения, не Ф.)*
- [Д][—] Строка: аватар (фото/инициалы на палитре) + имя 16 + email серым — типографика по
живой теме (v0.5.126); детальный Fd на устройстве.
## Экран 2: Шторка навигации — Ff 18.2% (9 open Ф / 11)
Веб-drawer приложения ↔ натив (drawer в модуле ОТСУТСТВУЕТ, есть только плоский список):
- [Ф][done] «Создать контакт» · натив: кнопка в шапке (функция есть).
- [Ф][done] «Все контакты» · натив: дефолтный список.
- [Ф][open] «Импорт контактов» — нет.
- [Ф][open] «Организационная структура» — нет (см. Экран 5).
- [Ф][open] «Без группы» (фильтр) — нет (групп нет).
- [Ф][open] Группы контакта: список групп — нет.
- [Ф][open] Группы контакта: «+» создать группу — нет.
- [Ф][open] Команды: открыть команду — нет (см. Экран 6).
- [Ф][open] Команды: «+» создать команду — нет.
- [Ф][open] Команды: «Показать все команды» — нет.
- [Ф][open] «Параметры контактов» — нет (см. Экран 7).
## Экран 3: Карточка контакта (просмотр) — Ff 42.1% (11 open Ф / 19)
Ряд действий (веб) ↔ натив (`ContactDetailContent`):
- [Ф][done] Написать письмо (конверт) · натив «Email» (ACTION_SENDTO mailto).
*расхождение: уходит во внешний почтовик, не в свой `MailComposeActivity` (план mail/072 п.3).*
- [Ф][open] Видеозвонок Talk (камера) — натив: «Позвонить» = системный дайлер (ACTION_DIAL), НЕ Talk.
*расхождение: другой канал (план mail/072 п.3).*
- [Ф][open] Профиль пользователя (силуэт) — нет.
Поля (веб-секции) ↔ натив:
- [Ф][done] Email: просмотр + клик→письмо; несколько адресов (`emailLines`).
- [Ф][done] Телефон: просмотр + клик→звонок; несколько (`phoneLines`).
- [Ф][done] Адрес.
- [Ф][done] Сайт (клик→браузер).
- [Ф][done] День рождения.
- [Ф][done] Организация/должность (subtitle).
- [Ф][done] Адресная книга (принадлежность).
- [Ф][open] «Социальная сеть» (ссылка на профиль) — нет.
- [Ф][open] «ID в объединении облачных хранилищ» (federated cloud ID) — нет.
- [Ф][open] «Подробное имя» (Фамилия/Имя/фонетика отдельными полями) — натив показывает только displayName.
Меню шапки:
- [Ф][open] «✏️ Править» — нет (Экран 4).
- [Ф][open] ⋯ «Скачать» (.vcf) — нет.
- [Ф][open] ⋯ «Копировать контакт» — нет.
- [Ф][open] ⋯ «Создать QR-код» — нет.
- [Ф][open] ⋯ «Исключить контакт из календаря Дней Рождений» — нет.
- [Ф][open] ⋯ «Удалить» — нет.
- [Д][—] Крупный аватар/имя/subtitle, поля-плитки, кнопки Email/Позвонить — по живой теме; Fd на устройстве.
## Экран 4: Правка/создание контакта (форма) — Ff 26.7% (11 open Ф / 15)
Веб-форма правки ↔ натив (`AddContactDialog` — только СОЗДАНИЕ, правки нет):
- [Ф][done] Имя · натив: поле в диалоге создания.
- [Ф][done] Телефон (значение) · натив: поле в диалоге.
- [Ф][done] Email (значение) · натив: поле в диалоге.
- [Ф][done] Сохранить · натив: `createContact` (vCard 3.0 PUT).
- [Ф][open] Правка существующего (загрузка контакта в форму, PUT If-Match) — нет.
- [Ф][open] «Добавить фото» — нет.
- [Ф][open] Должность/организация — нет в форме.
- [Ф][open] Тип телефона (Домашний/Рабочий/CELL/…) — нет (селектор типа).
- [Ф][open] Тип email — нет.
- [Ф][open] Адрес — нет в форме.
- [Ф][open] «Личные даты» (день рождения, дата-пикер) — нет.
- [Ф][open] «О себе» — нет.
- [Ф][open] «Часовой пояс» — нет.
- [Ф][open] «Группы контакта» (добавить в группу) — нет.
- [Ф][open] Выбор адресной книги при создании — нет (пишет в `books.firstOrNull()`).
- [Д][—] Веб — полноэкранная форма; натив — компактный диалог. Расхождение композиции (Fd на устройстве).
## Экран 5: Организационная структура — Ff 0.0% (4 open Ф / 4)
Отсутствует в нативе целиком:
- [Ф][open] Просмотр графа оргструктуры (карточки сотрудников, связи).
- [Ф][open] Селектор «График:».
- [Ф][open] Разворачивание/сворачивание узлов (`^N`).
- [Ф][open] Переход к карточке из узла.
## Экран 6: Команда (например «Тест») — Ff 0.0% (0 open Ф / 5; n-a 3)
Отсутствует в нативе:
- [Ф][open] Просмотр команды (название/владелец/участники).
- [Ф][open] «✏️ Править» команду.
- [Ф][open] «Копировать ссылку».
- [Ф][open] «Добавить» участника.
- [Ф][open] Список участников (роль «Владелец» и т.д.).
- [Ф][n-a] Создать «Папка» — зона `feature/files` (создаётся из Файлов).
- [Ф][n-a] Создать «Конференции» — зона `feature/talk`.
- [Ф][n-a] Создать «Календарь» — зона `feature/calendar`.
> n-a обоснование: три «Создать …» из карточки команды — кросс-модульные создающие потоки чужих
> модулей, достижимы из их приложений; в контактах мобильно-неприменимы. Команды (Circles/Teams)
> целиком — кандидат на согласование с лидом: нужен ли отдельный экран команд в мобильных Контактах.
## Экран 7: Параметры контактов (модальный шит) — Ff 0.0% (5 open Ф / 5)
Отсутствует в нативе:
- [Ф][open] «Сортировать по» (Имя/Фамилия/фонетика ×2/Отображаемое имя/Последнее изменение).
- [Ф][open] «Импорт контактов».
- [Ф][open] Адресные книги: список (Контакты / Недавние / Учётные записи).
- [Ф][open] Адресные книги: ⋯ (переименовать/удалить/шаринг книги).
- [Ф][open] «Новая адресная книга».
---
## Сводка и приоритеты (прошу приоритизацию у лида)
Ff_app **26.7%** по 7 экранам (Э1 100 · Э2 18.2 · Э3 42.1 · Э4 26.7 · Э5/6/7 0). Fd — предварительно.
Крупнейшие функциональные дыры (кандидаты P1–P5), совпадают с моим планом mail/072:
1. **Правка/удаление контакта** (Экран 4 + ⋯ карточки): edit-форма, `updateContact` PUT If-Match с
round-trip неизвестных полей vCard, `deleteContact`, выбор адресной книги, поля должность/адрес/
даты/о-себе/часовой пояс, **типы TEL/EMAIL**. Требует href+ETag в `DavContact`/`ContactEntity`
core-правка `CardDavClient`+Room, лид дал добро в mail/072 (условия: не менять list/create,
юниты vCard round-trip, гейт `:app:compileReleaseKotlin`, Room-миграция).
2. **Группы контакта** (Экран 2 + поле формы + фильтр «Без группы»): парс CATEGORIES → чипы-фильтр.
3. **Шторка навигации** (Экран 2): импорт, оргструктура, группы, команды, параметры — как единый drawer.
4. **Привязка к каналам** (Экран 3): «Email» → свой композер, «Позвонить» → Talk-звонок
(кросс-модуль feature/mail, feature/talk — согласую письмами, лид в копию).
5. **Параметры** (Экран 7): сортировка, импорт, управление адресными книгами.
Прочее: соц.сеть/cloud-ID/QR/скачать/копировать в карточке; оргструктура-граф; экран команд (см. n-a).
## Вопросы к лиду (b-dev-mobile)
- Порядок P1–P5? Мой дефолт (не жду ответа, начинаю с P1 edit/delete — целиком мой модуль +
твоё уже-данное добро на core-правку): 1→2→(4 кросс-модуль)→3→5.
- **Команды (Circles/Teams)** (Экран 6) — нужен ли отдельный экран команд в мобильных Контактах,
или считаем n-a целиком? Три «Создать …» я пометил n-a (чужие модули) — подтверди.
- **Оргструктура** (Экран 5) — в скоуп мобильных Контактов или n-a?
- Fd: гоню отдельным проходом на устройстве после ревью-сборки — сборка/версия по гейту (084) за тобой.
## Live-запись (mail/085, ограждения)
Read-only снят: edit/delete-флоу закрою live на forbion CRUDʼом СВОИХ `bmob-*` контактов
(создать→править→удалить свой же), не трогая реальные данные `cloude`. Пока правка edit/delete не
написана — веду из кода + юнит-тесты на vCard round-trip (обязательны по mail/072).
+143
View File
@@ -0,0 +1,143 @@
# Карта: Контакты (forbion, мобильная веб-версия)
Снято 2026-07-12 наблюдением мобильной вёрстки forbion (вьюпорт 390px, аккаунт `cloude`,
строго read-only по mail/073). Пишущие элементы («Создать контакт», «Импорт», «+» у групп/команд,
«Сохранить», «Удалить», «⋯» адресных книг) НЕ нажимались — их наличие и подписи сняты с экрана/DOM.
## Вход
Нижняя панель шелла → кнопка навигации (стрелка `<`, подпись раздела «Контакты») открывает
шторку приложения; либо прямой URL `/apps/contacts/`. Приложение открывается сразу списком
«Все контакты» (первый контакт автовыбран на широком экране; в мобильной вёрстке — только список).
Нижняя панель шелла (общая, зона лида, для ориентира): `<`/`>` — открыть/закрыть шторку
навигации приложения · конверт → Почта · «карточки» → Задачи · камера → Конференции ·
`≡` → шторка приложений. Панель плавающая поверх контента.
## Экран: Список контактов («Все контакты»)
**Шапка:** только поле поиска (pill на всю ширину). Заголовка экрана и кнопки «создать» в шапке НЕТ
(создание — в шторке навигации).
**Тело:** плоский список: аватар (фото или инициалы на цветном круге) + имя (16, тёмный) +
email (второй строкой, серый). Разделители-линии. Сортировка — по отображаемому имени
(настраивается в «Параметрах контактов»).
**Действия:**
- Ввод в поиск → фильтрация списка на лету (имя/email); справа в поле появляется `×` — очистить.
- Тап по контакту → «Экран: Карточка контакта».
- Тап `<` на нижней панели → «Экран: Шторка навигации».
## Экран: Шторка навигации (drawer слева, поверх списка)
**Верх:**
- «Создать контакт» (зелёная, слева иконка) + отдельный сегмент `+` справа — (не нажималось,
read-only; по аналогии с десктопом `+` = быстрое создание).
- В контексте команды над ней появляется вторая зелёная кнопка «Импорт контактов» + `+`.
**Пункты:**
- «Все контакты» [бейдж: 105] → список всех.
- «Организационная структура» → «Экран: Организационная структура».
- «Без группы» [105] → список контактов без групп (у `cloude` все 105 без групп).
- Секция «Группы контакта» [`+`] — пусто (групп нет); `+` = создать группу (не нажималось).
- Секция «Команды» [`+`]: «Тест», «admin», «all», «Администраторы поддержки», «Маркетинг»,
«Показать все команды». У активной команды справа `⋯` (меню; не раскрывалось).
Тап по команде → «Экран: Команда».
- Кнопка «Параметры контактов» (внизу шторки) → «Экран: Параметры контактов».
**Закрытие:** тап по контенту правее шторки (или `>` на нижней панели — кнопка-стрелка меняет
направление). NB: кнопка панели при открытой шторке имеет aria «Закрыть навигацию».
## Экран: Карточка контакта (просмотр)
**Шапка:** `←` (назад к списку) слева сверху.
**Тело (по центру):** большой аватар; имя крупно; подпись — должность/организация
(например «ооо--форбион», «Ген.дир»); ряд круглых кнопок-действий; секции полей.
**Кнопки-действия (круглые, состав зависит от контакта):**
- видеокамера → звонок/беседа Talk с пользователем (у системных контактов-пользователей),
- силуэт → профиль пользователя на сервере,
- конверт → написать письмо.
У обычного (не системного) контакта была только кнопка-конверт.
**Секции полей (просмотр; показываются только заполненные):**
- «Эл. почта» (значение в рамке-поле),
- «Социальная сеть» (ссылка на профиль `…/index.php/u/<user>`),
- «Подробное имя» (Имя, Фамилия отдельными строками),
- «ID в объединении облачных хранилищ» (federated cloud ID `user@forbion.f7cloud.ru`),
- «Адрес» (подтип, напр. «Город»),
- «Личные даты» (день рождения),
- «Адресная книга» (какой книге принадлежит: «Учётные записи» / «Контакты»).
**Действия:**
- «✏️ Править» (рядом с именем; ЕСТЬ только у контактов из редактируемых книг, у системных
из «Учётных записей» отсутствует) → «Экран: Правка контакта».
- `⋯` (справа от имени) → меню:
- системный контакт («Учётные записи»): «Скачать», «Копировать контакт», «Создать QR-код»;
- обычный контакт: те же + «Исключить контакт из календаря Дней Рождений», «Удалить»
(не нажималось).
## Экран: Правка контакта (после «Править»)
Заголовок вверху — «Создание нового контакта» (похоже, баг вебки: открыт существующий контакт,
все данные на месте).
**Тело (форма):**
- Большой аватар + зелёная кнопка «Добавить фото»;
- Поле имени; два поля должности (placeholder «Должност…» + значение, напр. «Ген.дир»);
- Кнопки: «✓ Сохранить…», `⋯`;
- «Телефон»: строка типа (дропдаун «Домашний» с `×` и ``) + поле значения;
- «Эл. почта»: тип + значение (та же пара);
- «Адрес»: подтип «Город» + поле;
- «Личные даты»: поле с датой (дата-пикер);
- «О себе»: многострочное поле;
- «Часовой пояс»: дропдаун;
- «Группы контакта»: дропдаун «Добавить контакт в гру…».
**Выход:** `←` — назад без сохранения (проверено, изменений не делалось).
## Экран: Организационная структура
**Шапка:** «График:» + селектор.
**Тело:** вертикальное дерево-граф: карточки сотрудников (аватар-инициал, имя зелёным,
счётчики «Управляет: N» / «Наблюдает: N»), связи линиями, узлы сворачиваются бейджем `^N`
на ребре. Пример: admin → g.kuts@proorder.ru.
## Экран: Команда (например «Тест»)
**Тело:**
- Аватар-инициал команды, название, «Владелец» + чип с аватаром (`you`);
- Кнопки: «✏️ Править» (зелёная), «Копировать ссылку»;
- Секция «Создавать»: кнопки-пилюли «Папка», «Конференции», «Календарь» — создание общих
ресурсов команды (не нажималось);
- Секция «Члены»: «Добавить» (не нажималось) + список участников (аватар, имя, роль «Владелец»).
- «Показать все команды» в шторке ведёт на список команд (у `cloude` контент совпал с экраном
команды — все команды и так в шторке).
## Экран: Параметры контактов (модальный шит, `×` для закрытия)
- Секция «Общие»: «Сортировать контракты по» — дропдаун: Имя / Фамилия /
Имя (фонетическое написание) / Фамилия (фонетическое написание) / Отображаемое имя (текущее) /
Последнее изменение;
- Кнопка «Импорт контактов» (не нажималось);
- Секция «Адресные книги», у каждой `⋯` (не раскрывалось):
- «Контакты» — 1 контакт, 0 групп;
- «Недавние контакты» — 0 контактов, 0 групп;
- «Учётные записи» — «Системная адресная книга, в которой хранятся все учетные записи»,
104 контакта, 0 групп;
- Кнопка «Новая адресная книга» (не нажималось).
## Пустые состояния / ошибки
- Поиск без совпадений: список пустеет (отдельной заглушки на 390px не замечено).
- Секция «Группы контакта» без групп — просто пустая (без заглушки).
- Состояния ошибок сети не воспроизводились (read-only, боевой сервер).
## Расхождения с нативным клиентом (бэклог b-mob-contacts)
- расхождение: **редактирование/удаление контакта** — в вебе «Править» (полная форма: фото, имя,
должность, телефон/почта С ТИПАМИ, адрес, даты, о себе, часовой пояс, группы) и «Удалить» в `⋯`;
в нативе ничего этого нет (план mail/072 п.1).
- расхождение: **типы полей** (Домашний/…) у телефона/почты — натив не парсит TYPE.
- расхождение: **группы контакта** (секция в шторке, поле в форме) — в нативе нет (план п.2).
- расхождение: **шторка навигации** (Все контакты/Орг.структура/Без группы/Группы/Команды/
Параметры) — в нативе только плоский список с поиском.
- расхождение: **организационная структура** (граф) — в нативе нет.
- расхождение: **команды** (экран команды, создание ресурсов, члены) — в нативе нет.
- расхождение: **параметры** (сортировка, импорт, управление адресными книгами, новая книга) —
в нативе нет; создание пишет в первую книгу без выбора.
- расхождение: **карточка**: соц.сеть, federated cloud ID, «Исключить из календаря ДР», QR-код,
«Скачать», «Копировать контакт» — в нативе нет; зато в нативе есть прямые «Email»/«Позвонить»
(системные интенты) — в вебе звонок идёт через Talk-кнопку у системных контактов.
- расхождение: **меню `⋯` карточки различается по книге** (системная без «Удалить») — нативу
при реализации правки учесть read-only книги.
- наблюдение: заголовок формы правки в вебе — «Создание нового контакта» (баг вебки, в нативе
не повторять).
## Примечания к методике
Снималось через same-origin iframe 390px внутри Chrome (окно 2560px нельзя ужать) — media queries
отрабатывают по ширине iframe, тема отдаёт мобильную вёрстку (`header__mobile`,
`header__mobile-bottom`, `app-content-wrapper--no-split`). Артефакт: плавающая нижняя панель в
iframe не отрисовывается (visibility:hidden не снимается темой), состав панели снят из DOM;
на реальном телефоне панель видна.
+160
View File
@@ -0,0 +1,160 @@
# Diff: Карточки (Deck) — итерация 1 (2026-07-13)
Ff_app: **22.1%** (53 open Ф / 76) · Fd_app: **предварительно** (веб-вёрстка сверена live через
iframe 390px; натив-дизайн-проход на устройстве — след. итерация) · n-a: 4
Сверка веб (карта `deck.md`, апстрим `nextcloud/deck v1.16.4` + тема forbion, live-сверка
Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`DeckScreen`/
`DeckCardSheet`/`DeckViewModel`, внутренний фронт-API `/apps/deck/api/v1.0`). Метод mail/083:
каждый пункт `[Ф|Д]`·`[open|done|n-a]`, Ff/Fd на экран. Дизайн (Fd) — на устройстве след. проход;
здесь Ф-функционал из кода + структурные Д, где очевидно. Всплывающие меню (mail/087) раскрыты
поимённо и сверены построчно.
> Контекст доступа: запись на forbion под `cloude` разрешена с ограждениями (mail/085 — только
> свои объекты `bmob-*`, уборка). Пишущие пункты ниже, помеченные `[open]` из-за «нет в нативе»,
> закрываются реализацией; «пишущее не проверено на живом» — сниму прогоном CRUD на доске
> `bmob-deck` после реализации.
---
## Экран 1: Навигация приложения (веб-дровер) — Ff 16.7% (10 open Ф / 12)
В нативе отдельного дровера приложения нет — вход = сразу список досок. Пункты веб-навигации:
- [Ф][open] «**Ожидающие выполнения**» (обзор по срокам) — экрана нет.
- [Ф][done] «**Все доски**» — натив показывает список досок (это дефолт-экран).
- [Ф][done] Открыть доску (тап пункта) — есть.
- [Ф][open] «**Добавить доску**» (инлайн: цвет + имя) — создания доски нет.
- [Ф][open] «**Импортировать доску**» — нет.
- [Ф][open] «**Настройки Deck**» — экрана настроек нет.
Всплывающее меню доски (⋯ на пункте, `AppNavigationBoard`) — веб ↔ натив (в нативе меню доски НЕТ):
- [Ф][open] «**Сведения о доске**» (сайдбар доски) — нет.
- [Ф][open] «**Редактировать доску**» (имя/цвет) — нет.
- [Ф][open] «**Клонировать доску**» — нет.
- [Ф][open] «**Архивировать/Разархивировать доску**» — нет (архивные доски просто скрыты).
- [Ф][open] «**Экспортировать доску**» — нет.
- [Ф][open] «**Напоминания о сроках**» (Все/Назначенные/Выкл.) — нет.
## Экран 2: Все доски (список) — Ff 50.0% (1 open Ф / 2)
- [Ф][done] Открыть доску (тап строки) — есть.
- [Ф][open] Поиск/фильтр списка досок (`navFilter`) — нет.
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски.
- [Д][—] Строка-карточка доски (bullet цвета + название, border/radius) — сверено, близко 1:1
(натив `DeckBoardRow`); детальный Fd — на устройстве.
## Экран 3: Обзор / «Ожидающие выполнения» — Ff 0.0% (2 open Ф / 2)
- [Ф][open] Дашборд карточек по срокам (Просроченные/Сегодня/Завтра/Ближайшие 7 дней/Позже/
Без назначенной даты) — экрана в нативе нет совсем.
- [Ф][open] Открыть карточку из обзора — нет (нет обзора).
- [Д][open] Композиция: 6 секций-капшенов в столбец, карточки внутри — отсутствует.
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 52.0% (12 open Ф / 25)
**Шапка доски (`Controls`):**
- [Ф][done] «**Добавить список**» — натив «+ Колонка» (`createStack`).
- [Ф][open] «**Применить фильтр**» (по метке / назначенному / статусу) — фильтров нет.
- [Ф][open] «**Режимы просмотра**» (Показывать архивные карточки / компактный режим / обложки) — нет.
- [Ф][open] «**Открыть подробности**» (сайдбар доски) — нет.
- [Ф][done] «**Добавить карточку**» — натив добавляет карточку из шапки колонки.
**Всплывающее меню колонки (⋯, `Stack`)** — в нативе меню колонки НЕТ:
- [Ф][open] «**Архивировать все карточки**» — нет.
- [Ф][open] «**Разархивировать все карточки**» — нет.
- [Ф][open] «**Удалить список**» — нет.
- [Ф][open] «**Изменить заголовок списка**» (инлайн-правка) — нет (переименования колонки нет).
- [Ф][done] «**Добавить карточку**» в колонку — есть (инлайн-поле «Имя карточки»).
**Карточка на доске (`CardItem` + бейджи):**
- [Ф][done] Открыть карточку (тап) — открывает лист-деталь.
- [Ф][done] Правка заголовка — есть (в листе-детали; веб правит инлайн — Д-нюанс).
- [Ф][done] Бейдж срока (`DueDate`) — есть.
- [Ф][open] Бейдж «**чек-пунктов**» (Todo items, напр. 0/9) — натив не показывает.
- [Ф][open] Бейдж «**комментариев**» (N, M непрочитанных) — не показывает.
- [Ф][done] Аватары назначенных — есть (отображение).
**Всплывающее меню карточки (⋯ / long-press, `CardMenuEntries`)** — в нативе overflow-меню НЕТ
(тап открывает лист; часть действий — кнопками листа):
- [Ф][done] «**Сведения о карточке**» — тап открывает лист-деталь.
- [Ф][done] «**Изменить заголовок**» — в листе.
- [Ф][open] «**Назначить мне / Снять назначение с меня**» — назначения из клиента нет (только чтение).
- [Ф][done] «**Отметить выполненной / невыполненной**» — чекбокс done есть.
- [Ф][open] «**Переместить/копировать карточку**» — есть перенос между колонками ОДНОЙ доски
(чипы), но нет копирования и переноса на ДРУГУЮ доску.
- [Ф][done] «**Архивировать/Разархивировать карточку**» — кнопка «В архив» есть.
- [Ф][done] «**Удалить карточку**» — кнопка «Удалить» есть.
- [Ф][open] «**Отменить удаление**» (Undo-тост) — натив удаляет сразу, без отмены.
- [Ф][done] Перенос карточки между колонками (в вебе drag-n-drop) — натив реализует переносом
через чипы «Колонка» (Д-нюанс: без DnD-жеста).
## Экран 5: Лист-деталь / сайдбар карточки (Свойства/Вложения/Комментарии/Активность) — Ff 33.3% (12 open Ф / 18)
**Свойства (Details):**
- [Ф][done] Метки: назначить/снять (toggle меток доски) — есть.
- [Ф][open] Метки: «**Создать новую метку**» — натив только тогглит существующие.
- [Ф][open] Назначение: «**Назначить пользователю…**» — нет (write).
- [Ф][open] Назначение: снять назначение — нет.
- [Ф][done] Срок: выбрать дату (DatePicker) — есть.
- [Ф][open] Срок: «**быстрые опции**» (Позже сегодня/Завтра/В выходные/На след. неделе) — нет.
- [Ф][done] Срок: «**Убрать срок**» — есть («Сбросить»).
- [Ф][done] Срок: «Отметить выполненной» из блока срока — есть (done).
- [Ф][done] Описание: редактировать — есть.
- [Ф][open] Описание: «**Просмотр/Справка по форматированию**» (rich-preview) — натив простое поле.
- [Ф][open] Описание: «**Добавить вложение**» из описания — нет.
- [Ф][n-a] Блок «Проекты» (`NcCollectionList`) — зависит от NC-аппа проектов, вне ядра клиента.
**Вложения (Attachments) — таба нет в нативе:**
- [Ф][open] «**Загрузить новые файлы**» — нет.
- [Ф][open] «**Поделиться из Файлов**» — нет.
- [Ф][open] Действия вложения (Скачать/Показать в Файлах/Удалить/Восстановить) — нет.
**Комментарии (Comments) — табы нет:**
- [Ф][open] «**Добавить комментарий**» (форма + Сохранить) — нет.
- [Ф][open] Читать ленту комментариев — нет.
**Активность (Activity):**
- [Ф][open] Просмотр активности карточки — нет.
**Шапка листа:**
- [Ф][done] Правка заголовка карточки — есть.
- [Ф][n-a] «Открыть в боковом / большом виде» — на мобильном единый лист, не применимо.
## Экран 6: Сайдбар доски (Общий доступ/Метки/Удалённые/Активность) — Ff 0.0% (10 open Ф / 10)
Всего экрана в нативе нет:
- [Ф][open] «**Общий доступ**»: пошарить доску, права (Can edit/share/manage), «Передать доску», удалить доступ (4 пункта).
- [Ф][open] «**Метки**»: добавить/редактировать/удалить метку доски (3 пункта).
- [Ф][open] «**Удалённые элементы**»: восстановить удалённые списки/карточки (2 пункта).
- [Ф][open] «**Активность**» доски (1 пункт).
## Экран 7: Диалог «Переместить/копировать карточку» — Ff 25.0% (3 open Ф / 4)
- [Ф][done] Переместить в колонку той же доски — есть (чипы в листе; Д: без диалога).
- [Ф][open] «**Выбрать доску**» (перенос на другую доску) — нет.
- [Ф][open] «**Копировать карточку**» — нет.
- [Ф][open] Диалог с селекторами «Доска/Список» — нет (переноса на др. доску нет).
## Экран 8: Настройки Deck — Ff 0.0% (3 open Ф / 3)
Экрана настроек в нативе нет:
- [Ф][open] «**Использовать увеличенный вид карточки**» — нет.
- [Ф][open] «**Показывать бейдж ID карточки**» — нет.
- [Ф][open] «**Показывать доски в календаре/задачах**» — нет.
- [Ф][n-a] «Настройки администратора» (ограничить создание досок группами) — админ-функция, не клиентская.
---
## Сводка по экранам
| Экран | Ff | open Ф / всего |
|---|---|---|
| 1. Навигация + меню доски | 16.7% | 10/12 |
| 2. Все доски (список) | 50.0% | 1/2 |
| 3. Обзор (по срокам) | 0.0% | 2/2 |
| 4. Доска (Controls/Колонка/Карточка) | 52.0% | 12/25 |
| 5. Лист-деталь карточки | 33.3% | 12/18 |
| 6. Сайдбар доски | 0.0% | 10/10 |
| 7. Move/copy карточки | 25.0% | 3/4 |
| 8. Настройки Deck | 0.0% | 3/3 |
| **Ff_app (среднее)** | **22.1%** | **53/76** |
## Приоритеты (черновик, жду визы лида — mail/071 план 1→2→3)
- **P1 (в своём модуле, наибольший охват):** меню карточки (Назначить мне/Переместить-копировать/
Undo при удалении); бейджи чек-пунктов и комментариев; меню колонки (Удалить список/Архивировать
все/Переименовать); «быстрые опции» срока.
- **P2:** сайдбар карточки — Комментарии и Вложения (Вложения зависят от медиа-слоя b-mob-files,
mail/083 — согласую точечно); назначение исполнителей (write через `/cards/{id}/assign`).
- **P3:** обзор «Ожидающие выполнения» (дашборд по срокам); фильтры доски; CRUD досок
(создать/править/архив/клон/экспорт-импорт); сайдбар доски (шаринг/метки/удалённые); настройки.
- **n-a (в финал с обоснованием):** проекты-коллекции, «боковой/большой вид» карточки,
админ-настройки, drag-n-drop-жест (заменён чипами — функц-эквивалент есть).
## Медиа-инвентарь (mail/081 §3 / mail/083 B)
В «Карточках» собственных медиа-просмотрщиков нет; вложения карточки в вебе открываются общим
NC-viewer. В нативе вкладки «Вложения» пока нет вовсе — когда появится (P2), просмотр
переиспользую через общий вход `feature/files` (Coil/Media3/PdfRenderer), НЕ плодя своё. Точки
интеграции согласую с b-mob-files, когда дойду до вложений.
+205
View File
@@ -0,0 +1,205 @@
# Карта: Карточки (Deck)
Приложение «Карточки» (`/apps/deck/`) на forbion.f7cloud.ru. Структура кнопок/меню/переходов
снята **из кода** (метод mail/078): апстрим `nextcloud/deck` **v1.16.4** (`src/**/*.vue` — точная
версия с forbion, канон README) + мобильная тема forbion
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-deck.css`,
`css/pages/app-deck/_app-deck.css`). Живой мобильный web (Browser 1, `cloude`, mail/079) — только
для визуальной сверки «как выглядит/порядок»; **строго read-only** (mail/073): ничего не
создаётся/не меняется/не удаляется. Приватность: реальные названия досок/карточек не
фиксируются — только структура UI и дерево переходов.
> Статус визуальной сверки на живом forbion: **выполнена 2026-07-12** (Browser 1, `cloude`,
> мобильный вьюпорт через same-origin iframe 390px, read-only — ни одной пишущей операции).
> Реальные русские подписи forbion внесены ниже; расхождения кода ↔ живой темы помечены
> `сверено:`. Пройдены: Обзор, Все доски, доска (Controls+стеки+карточки), сайдбар карточки.
## Компоненты-источники (nextcloud/deck v1.16.4)
Навигация: `navigation/AppNavigation.vue` (+ `AppNavigationAddBoard`, `AppNavigationBoard`,
`AppNavigationBoardCategory`, `AppNavigationImportBoard`, `BoardCloneModal`, `BoardExportModal`).
Список досок: `boards/Boards.vue`, `boards/BoardItem.vue`. Обзор: `overview/Overview.vue`
(+ `dashboard/Card.vue`, `views/DashboardToday|Tomorrow|Upcoming.vue`). Доска: `board/Board.vue`,
`board/Stack.vue`, `Controls.vue`. Карточка на доске: `cards/CardItem.vue`, `cards/CardMenu.vue`,
`cards/CardMenuEntries.vue`, `cards/CardBadges.vue`, `cards/AvatarList.vue`, `cards/badges/DueDate.vue`,
`cards/badges/CardId.vue`. Сайдбар карточки: `card/CardSidebar.vue` + табы `CardSidebarTabDetails`
(→ `TagSelector`, `AssignmentSelector`, `DueDateSelector`, `Description`, `AttachmentList`),
`CardSidebarTabAttachments`, `CardSidebarTabComments` (+ `CommentForm`, `CommentItem`),
`CardSidebarTabActivity`. Сайдбар доски: `board/BoardSidebar.vue` + `SharingTabSidebar`,
`TagsTabSidebar`, `DeletedTabSidebar`, `TimelineTabSidebar`. Диалоги: `CardMoveDialog.vue`,
`CardCreateDialog.vue`, `BoardSelector.vue`, `CardSelector.vue`. Настройки: `DeckAppSettings.vue`
(+ `KeyboardShortcuts.vue`).
## Вход
Шторка приложений (по «трём полоскам») → «Карточки», или прямой URL `/apps/deck/`. Оболочку
(нижняя панель, шторка, бургер) описывает `shell.md` (зона лида) — здесь только специфика
приложения. На мобильном (узкий вьюпорт) тема (`_mobile-deck.css`) раскладывает **доску
колонками по вертикали** (`.smooth-dnd-container.horizontal { flex-direction: column }`),
навигацию убирает под бургер/шторку навигации, а сайдбары/модалки/настройки раскрывает **на
весь экран** (`modal-container height:100%`, `modal-header` скрыт).
## Экран: Навигация приложения (левая шторка — `AppNavigation`)
На мобильном — за «бургером»/шторкой навигации приложения (не нижняя панель оболочки).
**Тело (пункты по порядку, `сверено:` подписи с forbion):**
- «**Ожидающие выполнения**» (Upcoming cards, `to="/"`) → экран «Обзор» (дашборд по срокам).
`сверено:` на forbion пункт назван «Ожидающие выполнения», это и есть лендинг приложения.
- «**Все доски**» (All boards, `to="/board"`, иконка deck) → список досок.
- Список **личных досок** (каждая — `AppNavigationBoard`, см. ниже), сгруппировано по категориям
(`AppNavigationBoardCategory`).
- «**Добавить доску**» (Add board, иконка +) → инлайн-создание: цветной bullet (палитра-пикер) +
поле «**Имя доски**» (Board name) + кнопки «Отмена правки»/«Сохранить доску». *(write — не жму.)*
- «**Импортировать доску**» (Import board, иконка upload) → диалог импорта (файл). *(write — не жму.)*
- Внизу: «**Настройки Deck**» (Deck settings, шестерёнка) → модалка настроек.
> `сверено:` в мобильной навигации forbion пунктов «**Архивные доски**» (`/board/archived`) и
> «**Доски, доступные вам**» (`/board/shared`) НЕ видно — в коде они есть (`AppNavigation`), но
> на узком вьюпорте тема их не выводит (или показывает только при непустом списке). Для нативного
> клиента это скорее плюс: паритет по видимой мобильной навигации не требует этих двух видов.
### Меню доски (⋯ на пункте доски — `AppNavigationBoard` NcActions)
Порядок пунктов из кода:
- «**Сведения о доске**» (Board details, icon-info) → открывает сайдбар доски (см. ниже).
- «**Редактировать доску**» (Edit board, icon-rename; если canManage и не архивная) →
инлайн-правка названия/цвета.
- «**Клонировать доску**» (Clone board; если canCreate) → модалка `BoardCloneModal`.
- «**Разархивировать доску**» / «**Архивировать доску**» (Unarchive/Archive board) — тумблер.
- «**Экспортировать доску**» (Export board, icon-download) → модалка `BoardExportModal`.
- «**Вкл./выкл. напоминания о сроках**» → подменю «**Напоминания о сроках**» (Due date reminders):
«Все карточки» (All cards) / «Назначенные карточки» (Assigned cards) / «Выкл.» (Off).
*(все write — не выполняю.)*
## Экран: Все доски (список — `Boards.vue`)
**Тело:** таблица-список досок. Тема (`_app-deck.css`, `_mobile-deck.css`) делает каждую строку
карточкой (border, radius 8px). Колонки-заголовки (`board-list-header-row`):
- «**Имя доски**» (Board name) — bullet цвета доски + название (тап по строке → открыть доску).
- «**Участники**» (Members) — аватары ACL (`AvatarList`; если >5 — «+N» иконкой more).
- Поле фильтра списка `navFilter` (быстрый поиск по названию доски) — `визуал(?)` (положение
на мобильной вёрстке).
**Пустое состояние:** стандартный `NcEmptyContent` (тема подставляет deck-иконку).
## Экран: Доска (`Board.vue` + `Controls.vue`)
**Шапка (`Controls`)** — `сверено:` на мобильном forbion в шапке доски 4 контрола (иконками):
- «**Добавить список**» (Add list) — инлайн-поле «**Имя списка**» (List name) + подтверждение.
*(write — не жму.)*
- «**Применить фильтр**» (иконка-воронка; активная = primary). Раскрывает поповер фильтров:
- «**Фильтр по метке**» (Filter by tag) — чекбоксы по меткам доски.
- «**Фильтр по назначенному**» (Filter by assigned user) — «Без назначения» (Unassigned) +
список пользователей доски.
- «**Фильтр по статусу**» (Filter by status) — «Открытые и выполненные» / «Открытые» / «Выполненные».
- «**Режимы просмотра**» (View modes, иконка-сетка) — `сверено:` поповер с переключателями:
«**Показывать архивные карточки**», «**Выбор компактного или обычного режима просмотра**»,
«**Показывать обложки карточек**». *(в карте кода этого блока не было — добавлено по сверке.)*
- «**Открыть подробности**» (Open details, ⋯) → сайдбар доски (Общий доступ/Метки/Удалённые/Активность).
- «**Добавить карточку**» (Add card) в коде тоже есть в Controls — на мобильном быстрый ввод идёт
из шапки колонки (см. Stack). *(write — не жму.)*
**Тело:** колонки-списки (`Stack`) по вертикали (мобильная тема), в каждой — карточки.
**Состояния:** «Загрузка доски» (Loading board) / «Доска не найдена» (Board not found) /
пусто «**Нет доступных списков**» (No lists available) + «Создать новый список, чтобы добавлять
карточки» + поле «Имя списка».
### Колонка-список (`Stack.vue`)
**Шапка колонки:**
- Заголовок списка — тап (canManage) → инлайн-правка «**Изменить заголовок списка**» (Edit list title).
- Меню колонки (⋯, `NcActions`): «**Архивировать все карточки**» (Archive all cards) /
«**Разархивировать все карточки**» (Unarchive all cards) → подтверждение
(«Архивировать все карточки в этом списке» + Архивировать/Отмена); «**Удалить список**»
(Delete list). *(все write — не выполняю.)*
- «**Добавить карточку**» (Add card) → инлайн-поле «**Имя карточки**» (Card name). *(write — не жму.)*
**Тело:** карточки (`CardItem`), drag-n-drop переносом между колонками (мышью/тачем).
### Карточка на доске (`CardItem` + `CardBadges`)
- Тап по карточке → сайдбар/деталь карточки.
- Заголовок (тап при canEdit / клавиша `E` → инлайн-правка «Edit card title»).
- Бейджи (`CardBadges`): срок (`DueDate` — цвет по просрочке, «выполнено»→галочка), счётчик
«**чек-пунктов**» (Todo items), счётчик «**комментариев**» (N comments, M unread), аватары
назначенных (`AvatarList`), ID карточки (если включён бейдж ID в настройках).
- Меню карточки (⋯ / long-press, `CardMenuEntries`): «**Сведения о карточке**» (Card details) →
сайдбар; «**Изменить заголовок**» (Edit title); «**Назначить мне**» / «**Снять назначение с
меня**» (Assign to me / Unassign myself); «**Отметить выполненной**» / «**Отметить
невыполненной**» (Mark as done / not done); «**Переместить/копировать карточку**» (Move/copy
card) → диалог; «**Архивировать**» / «**Разархивировать карточку**» (Archive/Unarchive card);
«**Удалить карточку**» (Delete card) — с тостом «Карточка удалена» + «Отменить» (undo).
*(все write — не выполняю.)*
- Клавиши на карточке: `E` правка заголовка, `S` назначить себя, archive/done по хоткею (см. настройки).
## Экран: Обзор / Предстоящие карточки (`Overview.vue`)
Дашборд карточек по срокам (`to="/"`, заголовок экрана «**Ожидающие выполнения**» + кнопка «+»),
колонки-секции (мобильная тема ставит их в столбец). `сверено:` реальные подписи forbion:
«**Просроченные**», «**Сегодня**», «**Завтра**», «**Ближайшие 7 дней**», «**Позже**»,
«**Без назначенной даты**». Каждая секция — список `dashboard/Card.vue` (карточки с меткой,
иконкой описания и ⋯). Состояние загрузки: «Загрузка отфильтрованного вида».
## Сайдбар карточки (`CardSidebar` — на мобильном во весь экран)
**Шапка:** заголовок карточки (редактируемый), меню (⋯): «**Открыть в боковом виде**» (Open in
sidebar view) / «**Открыть в большом виде**» (Open in bigger view). Подпись «Изменено … ⸱ Создано …».
**Табы (по порядку)** — `сверено:` на forbion таб Details назван «**Свойства**», далее
«**Вложения**», «**Комментарии**», «**Активность**»:
- **Свойства** (Details) — `CardSidebarTabDetails`, порядок секций (сверено на живой карточке:
метка → назначение → срок → описание):
1. «**Назначить метку карточке…**» (`TagSelector`) — комбобокс «Выбрать или создать метку…»,
можно «Создать новую метку».
2. «**Назначить пользователю/группе/команде**» (`AssignmentSelector`) — «Выбрать пользователя…».
3. «**Назначить срок карточке…**» (`DueDateSelector`) — «Установить срок» / «Добавить срок»;
быстрые опции: «Позже сегодня», «Завтра», «В эти выходные», «На след. неделе»; «**Выбрать
дату**» (Choose a date) → пикер; «**Убрать срок**» (Remove due date); «**Отметить
выполненной**» (Mark as done); при наличии — «Срок: …»/«Не выполнено»; «Архивировать
карточку».
4. (если включены проекты) блок связанных проектов (`NcCollectionList`).
5. «**Описание**» (`Description`) — «Редактировать описание» / «Просмотр описания», «Справка по
форматированию», «Добавить вложение», плейсхолдер «Напишите описание …»; статусы «(Не
сохранено)»/«(Сохранение…)».
- **Вложения** (Attachments) — `CardSidebarTabAttachments` (+ `AttachmentList`): «**Загрузить новые
файлы**» (Upload new files), «**Поделиться из Файлов**» (Share from Files); по элементу:
«Показать в Файлах», «Скачать» (Download), «Удалить вложение», «Восстановить вложение»; drop-зона
«Перетащите файлы для загрузки»; «Эта доска только для чтения».
- **Комментарии** (Comments) — `CardSidebarTabComments` (+ `CommentForm`): поле ввода + «Сохранить»;
пусто «Пока нет комментариев. Начните обсуждение!».
- **Активность** (Activity) — `CardSidebarTabActivity` (если включён Activity-апп).
*(все действия табов — write, на forbion не выполняю.)*
## Сайдбар доски (`BoardSidebar` — во весь экран на мобильном)
Табы: «**Общий доступ**» (Sharing) — `SharingTabSidebar`: «Поделиться доской с пользователем,
группой или командой …», права по ACL (Can edit / Can share / Can manage / Owner), «Передать
доску» (Transfer) с подтверждением, «Удалить» доступ; «**Метки**» (Tags) — `TagsTabSidebar`:
список меток, «Добавить новую метку», по метке — «Редактировать»/«Удалить»; «**Удалённые
элементы**» (Deleted items) — `DeletedTabSidebar`: «Удалённые списки» / «Удалённые карточки»
(восстановление); «**Активность**» (Activity). *(все write — не выполняю.)*
## Диалог: Переместить/копировать карточку (`CardMoveDialog`)
- «**Выбрать доску**» (Select a board) — комбобокс.
- «**Выбрать список**» (Select a list; «Нет доступных списков», если пусто).
- Кнопки «**Переместить карточку**» (Move card) / «**Копировать карточку**» (Copy card).
## Модалка: Настройки Deck (`DeckAppSettings` — во весь экран на мобильном)
- **Общее** (General): «**Использовать увеличенный вид карточки**» (Use bigger card view).
- **Внешний вид** (Appearance): «**Показывать бейдж ID карточки**» (Show card ID badge),
«**Показывать доски в календаре/задачах**» (Show boards in calendar/tasks).
- **Настройки администратора** (Admin settings, если админ): «Ограничить создание досок группами».
- **Комбинации клавиш** (`KeyboardShortcuts`): действия доски (Scroll sideways, навигация
стрелками, Escape закрыть карточку, Ctrl+F поиск, F фильтры, X сброс фильтров, ? справка) и
карточки (Enter/Space открыть, E правка заголовка, S назначить себя). В теме forbion секция
клавиш может быть скрыта на мобильном — `визуал(?)`.
## Расхождения с нативным клиентом (`feature/deck`) — бэклог
Нативный модуль (`DeckRepository`/`DeckScreen`/`DeckCardSheet`/`DeckViewModel`) сейчас покрывает:
список неархивных досок, доску колонками, карточки (метки/срок/исполнители-**чтение**/done/
счётчик), деталь карточки (правка названия/описания, срок через пикер, done, toggle меток,
перенос между колонками, архив, удаление), создание карточки и колонки, учёт PERMISSION_EDIT.
**Отсутствует против веба (приоритетный бэклог):**
- **Обзор/Предстоящие карточки** — дашборда по срокам (Просрочено/Сегодня/…/Без срока) нет.
- **Архивные доски** и **Доски, доступные вам** — отдельных видов нет (архивные доски просто скрыты).
- **Фильтры доски** — по метке / назначенному / статусу; поиска по доске нет.
- **Исполнители — только чтение**: нет «Назначить мне/пользователю», снятия (совпадает с долгом №1
плана mail/071, п.2).
- **Комментарии** и **Вложения** карточки — нет совсем (вложения явно в зоне, mail/065).
- **Меню/CRUD колонки**: правка заголовка списка, «Архивировать/Разархивировать все карточки»,
«Удалить список» — в приложении нет (только создание).
- **CRUD досок**: создать/править/клонировать/архивировать/экспортировать/импортировать доску —
нет; переименования колонок нет.
- **Move/copy на другую доску** — нативно только перенос между колонками одной доски.
- **Метки (Tags) доски** — управления (создать/править/удалить) нет; toggle существующих есть.
- **Sharing / Deleted items / Activity** доски — нет.
- **Быстрые опции срока** (Позже сегодня/Завтра/Выходные/След. неделя) — в пикере нет.
- **Режимы просмотра доски** (`сверено:` шапка forbion): показ архивных карточек, компактный/
обычный режим, обложки карточек — в нативном клиенте нет.
- **Напоминания о сроках** (notify-due) и **бейдж ID / увеличенный вид** из настроек — нет.
- **Undo** при удалении карточки — в вебе есть тост-отмена; в нативном удаление сразу.
- Мелочь UI: счётчики чек-пунктов и комментариев на карточке доски — не показываются.
+41
View File
@@ -0,0 +1,41 @@
# Расхождения: Файлы (web forbion ↔ нативный feature/files)
Список несоответствий по методу mail/081 (итерация сверки). Вход — карта
[`files.md`](files.md). Сверка **по коду** `feature/files` (состав/функции); попиксельная
вёрстка по CSS-теме и визуал — отдельным проходом после сборки на устройстве.
Обозначения: **[Ф]** функционал · **[Д]** дизайн/вёрстка · приоритет P1(высокий)…P3(низкий).
Статус: `open` — не начато, `wip` — в работе, `done` — закрыто и сверено повторно.
## Итерация 1 — сверка по коду (2026-07-12)
| # | Элемент/функция | В вебе | В нативе | Тип | Приор. | Статус |
|---|---|---|---|---|---|---|
| D1 | Панель «Подробно» — вкладки | 4: Общий доступ / **Версии** / **Чат** / События | 2: Sharing, Events (`FileDetailsTab`, FilesModels.kt:67) — нет «Версии», «Чат/комментарии» | Ф | P2 | open |
| D2 | Фильтры-чипы | «Тип файла» (мультивыбор 8 типов), «Изменён» (5 пресетов дат), «Участники» (по людям) | Чипы есть, но обработчики — заглушки: Тип/Изменён → сортировка, Участники → пустой `{}` (FilesScreen.kt:224-226). Фильтрации нет | Ф | P1 | open |
| D3 | Групповые действия (bulk) | 6: избранное, метки, переместить/копировать, скачать, архивировать, удалить | 3: Favorite, Download, Delete (`FilesBulkAction`, FilesComponents.kt:1064); Download → заглушка `showWebOnlyMessage` | Ф | P1 | open |
| D4 | Шэры — создание | Внутренние ссылки: пользователь (type 0) / группа (type 1) + публичная (type 3) | Создаётся только `shareType=3` публичная ссылка (`createPublicShareLink`, FilesApiRepository.kt:174); чтение чужих шэров есть (`fetchShares` :199) | Ф | P1 | open |
Снято (не расхождение): меню «Новый» — нативный `FilesCreateMenu.kt` содержит весь веб-список
(загрузка + 10 «Создать новое») и даже пункт сверх («Добавить описание папки»).
## Инвентарь открывашек файлов (для медиа-воркстрима, mail/081 §3)
Текущее состояние нативных просмотрщиков в `feature/files`:
| Тип | Форматы | Как открывается сейчас | Пробел |
|---|---|---|---|
| Изображения | jpg/jpeg/png/gif/webp/bmp/heic/heif (`OpenableFiles.kt:10`) | Нативно `ImageViewerActivity` (Coil AsyncImage) | — (svg нет в списке — добавить?) |
| Офис | doc/docx/xls/xlsx/xlsm/… (`OfficeFiles.kt`) | Collabora/Richdocuments в WebView | — (есть) |
| Текст/Markdown | md/markdown/txt/text/org/log (`WebOpenableFiles.kt:17`) | Нативный `MarkdownEditorScreen` | — (есть) |
| Доска | .whiteboard (`WebOpenableFiles.kt:9`) | WebView (Excalidraw, realtime — нативно не делаем) | — (осознанно web) |
| **PDF** | pdf | Нет нативного вьюера → системный интент/`LocalFileOpener` или «тип не поддерживается» (FilesViewModel.kt:495) | **[Ф] P1 — нужен нативный PDF-вьюер** |
| **Видео** | mp4/webm/mkv/mov | Нет нативного плеера → системный интент | **[Ф] P2 — нативный видеоплеер** |
| **Аудио** | mp3/aac/ogg/wav/flac | Нет нативного плеера → системный интент | **[Ф] P2 — нативный аудиоплеер** |
Медиа-архитектуру (нативные плееры vs webview/интенты) определяет лид — жду письмо-раскладку
(mail/081 §3). До неё PDF/видео/аудио уходят в системный `ACTION_VIEW` (chooser «Открыть с помощью»).
## Осталось для итерации 2 (нужна сборка на устройстве)
- [Д] попиксельная вёрстка всех экранов по CSS-теме `_mobile-files.css`.
- [Ф] дерево «Поиска», переходы после открытия PDF/аудио/видео (визуальная сверка).
+132
View File
@@ -0,0 +1,132 @@
# Карта: Файлы
Источники (метод mail/078): **живой мобильный web** forbion (сессия Browser 1, аккаунт
`cloude`, iframe ~390px, read-only — mail/073/075/079) для внешнего вида/порядка +
**апстрим `nextcloud/server apps/files/src`** для состава кнопок/меню (сверено: `filters/`,
`actions/`) + тема `forbion-theme/.../_mobile-files.css` (селекторы элементов окна).
Приватность: реальные ФИО/почты сотрудников, права доступа и инвентарь бизнес-папок не
фиксируются — только структура UI и дерево переходов.
## Вход
Приложение «Файлы» — стартовое (нижняя панель / шторка приложений «Файлы»). URL `/apps/files/files`.
Оболочку (нижняя панель, шторка, бургер) описывает `shell.md` (зона лида) — здесь только
специфика приложения.
## Экран: Все файлы (главный / список папки)
**Шапка (сверху вниз):**
- Пилюля «Поиск» (Объединённый поиск) → экран поиска (см. ниже).
- Иконка-сетка справа → «Переключиться на режим просмотра сеткой» (тумблер список⇄сетка).
- Кнопка «Открыть навигацию [n]» → шторка навигации файлов (см. «Навигация файлов»).
- Кнопка «Новый» → меню создания/загрузки (см. «Меню Новый»).
- Хлебные крошки: 🏠 (корень) → сегменты пути; каждый сегмент кликабелен → переход к папке.
- Чипы-фильтры: «Тип файла», «Изменён», «Участники» (в корне — три; внутри папки «Участники»
пропадает, остаются «Тип файла»/«Изменён»).
**Тело:**
- Блок недавних/предложенных файлов (горизонтальная лента карточек: иконка типа + имя +
«Последнее обновление: …»); тап по карточке → открытие файла.
- Список папок/файлов (строки): чекбокс выделения · иконка типа (звёздочка = избранное) ·
имя · инлайн-кнопка «Скачать» (для файлов) · кнопка «Действия» (⋮).
- Тап по строке-папке → открыть папку (крошки наращиваются, URL `?dir=/Путь`).
- Тап по строке-файлу → открыть файл (по типу, см. «Открытие файла»).
- Чекбокс → режим выделения (см. «Режим выделения»).
- «Действия» (⋮) → контекстное меню файла/папки (см. ниже).
**Пустое состояние (папка без файлов):** иконка + «Здесь нет файлов» + подзаголовок
«Загрузите что-нибудь или синхронизируйте со своими устройствами!».
## Меню «Новый» (создание/загрузка)
Открывается кнопкой «Новый». Два раздела:
- **Загрузить с устройства:** «Загрузить файлы», «Загрузить папки».
- **Создать новое:** «Новая папка», «Запрос на создание файла», «Новая диаграмма»,
«Новая доска», «Новая презентация», «Новая таблица», «Новый документ»,
«Новый текстовый файл», «Создать папку шаблонов».
- (read-only: пункты только просмотрены, ничего не создавалось.)
## Навигация файлов (шторка «Открыть навигацию»)
Список представлений + дерево:
- «Все файлы», «Личные файлы», «Недавно изменённые», «Избранные»,
«Опубликованные ресурсы», «Командные папки», «Метки», «Внешнее хранилище»,
«Дерево папок», «Корзина».
- Внизу — индикатор квоты («Использовано N GB»).
- Каждый пункт → соответствующее представление списка файлов.
## Фильтры
- **«Тип файла»** → мультивыбор типов: Документы, Таблицы, Презентации, PDFs, Папки, Звук,
Изображения, Видео (`TypeFilter.ts`).
- **«Изменён»** → пресеты по дате (`ModifiedFilter.ts`): Сегодня, Последние 7 дней,
Последние 30 дней, В этом году, В прошлом году.
- **«Участники»** → фильтр по людям с доступом (даёт `files_sharing`, не ядро files) — чип есть
в корне «Все файлы», внутри папки пропадает.
## Контекстное меню (⋮) — Папка
«Добавить в избранное», «Подробно», «Варианты обмена», «Управление метками»,
«Переименовать», «Переместить или копировать», «Установить напоминание» ›,
«Открыть локально», «Скачать», «Архивировать в...» ›, «Удалить папку».
(Сверено с `apps/files/src/actions/`: favorite, sidebar=Подробно, moveOrCopy, rename,
editLocally=«Открыть локально», download, delete, openFolder; метки/обмен/напоминание/архив —
из соответствующих приложений тенанта.)
## Контекстное меню (⋮) — Файл
«Добавить/Удалить из избранных», «Подробно», строка-статус общего доступа (если расшарен),
«Управление метками», «Переименовать», «Переместить или копировать»,
«Установить напоминание» ›, «Сохранить как…» › (для офисных — форматы),
«Открыть локально», «Скачать», «Архивировать в...» ›, «Удалить файл».
## Режим выделения (bulk)
Отметка чекбокса → шапка списка меняется на: «Выбран N» (слева) + кнопка «Действия» (справа).
Меню «Действия» (групповое): «Добавить в избранное», «Управление метками»,
«Переместить или копировать», «Скачать», «Архивировать в...» ›, «Удалить папку/файлы».
## Экран: Подробно (панель сведений о файле)
Открывается пунктом «Подробно» в ⋮. Шапка: имя файла · × закрыть · владелец/аватар ·
«<дата изменения> / <размер>». Четыре вкладки-иконки:
- **Общий доступ** (людской значок): подсекции «Внутренние ссылки» (поле «Введите имена или
команды», список получателей с выпадающим правом «Можно редактировать/…», «Другие
пользователи, имеющие доступ», «Внутренняя ссылка / Для тех, у кого уже есть доступ» +
копировать), «Внешние ссылки» (поле e-mail, «Создать общедоступную ссылку» +),
«Дополнительные ссылки». Внизу — ряд быстрых иконок связанных приложений.
- **Версии** — история версий файла.
- **Чат** — комментарии к файлу.
- **События** — активность/лента событий по файлу.
- (read-only: панель только просмотрена; персональные данные получателей не фиксируются.)
## Открытие файла (по типу)
Тап по файлу открывает вьюер/редактор по типу (наблюдалось частично; часть — (?) по иконкам/
категориям типов):
- Офисные (Таблицы/Документы/Презентации) → редактор Collabora/Richdocuments в WebView.
- `.md` / текст → редактор текста/markdown.
- Изображения → просмотрщик изображений.
- `.whiteboard` / «доска» → редактор доски (WebView).
- PDF → просмотрщик PDF. *(точный переход для PDF/аудио/видео — (?), не открывал.)*
## Экран: Поиск
Пилюля «Поиск» → объединённый поиск (Nextcloud unified search) по файлам и приложениям.
*(детальное дерево поиска не раскрывал — (?).)*
## Расхождения с нативным клиентом (сверено по коду `feature/files`, 2026-07-12)
Сверка **состава/функций** по коду (не попиксельная — та ждёт сборки на устройстве, задача №1
mail/068). Итог: **4 подтверждённых расхождения, 1 снято.**
- ✅ снято — меню «Новый»: нативный `FilesCreateMenu.kt` содержит весь веб-список (загрузка +
10 пунктов «Создать новое») и даже больше («Добавить описание папки»). Соответствует.
- `расхождение:` **панель «Подробно» — 2 вкладки вместо 4.** `FileDetailsTab` (FilesModels.kt:67)
= {Sharing, Events}; в вебе 4 (Общий доступ / **Версии** / **Чат** / События). Нет вкладок
«Версии» и «Чат/комментарии».
- `расхождение:` **фильтры-чипы — заглушки.** `FilesFilterChips` рисует «Тип файла/Участники/
Изменен», но обработчики (FilesScreen.kt:224-226): «Тип файла»→сортировка по типу,
«Изменен»→сортировка по дате, «Участники»→пустой `{}`. В вебе это фильтры: Тип файла
(мультивыбор 8 типов), Изменён (5 пресетов дат), Участники (по людям). Функционал фильтрации
отсутствует.
- `расхождение:` **групповые действия — 3 из 6.** `FilesBulkAction` (FilesComponents.kt:1064)
= {Favorite, Download, Delete}, причём Download → `showWebOnlyMessage("Массовое скачивание")`
(заглушка). В вебе 6: Добавить в избранное, Управление метками, Переместить или копировать,
Скачать, Архивировать в…, Удалить. Нет меток/перемещения-копирования/архива; массовое
скачивание не реализовано.
- `расхождение:` **шэры — только публичная ссылка.** `createPublicShareLink` (FilesApiRepository.kt:174)
создаёт лишь `shareType=3`. `fetchShares` (:199) читает и отображает существующие шэры любого
типа, но СОЗДАНИЕ внутренних шэров (пользователь=0/группа=1) отсутствует. В вебе панель
«Внутренние ссылки» это умеет. = план п.3 mail/068.
Осталось (нужна сборка на устройстве, попиксельно): вёрстка по CSS-теме, переходы PDF/аудио/видео,
дерево поиска.
+46
View File
@@ -0,0 +1,46 @@
# Diff: Почта (веб forbion ↔ нативный `feature/mail`)
Итерация 1 (по mail/081). Вход — карта `mail.md` (живой forbion, аккаунт `cloude`, read-only) +
код модуля `feature/mail` на 91aaaba. Тип: **Ф** — функционал, **Д** — дизайн/вёрстка.
Приоритет: P1 (болит/ядро), P2 (заметно), P3 (мелочь/косметика).
Статус: `open` — есть в вебе, нет/иначе в нативе; `device?` — требует проверки на устройстве.
> Общий вывод: нативный клиент функционально зрелый — расширенный поиск (все поля),
> подтверждение прочтения, важность/спам/метки/переместить/создать задачу/создать событие/
> запланированная отправка **уже есть и совпадают**. Ниже — только реальные пробелы.
## Функциональные
| # | Элемент / функция | В вебе (forbion) | В нативе (`feature/mail`) | Тип | Приор. | Статус |
|---|---|---|---|---|---|---|
| 1 | Черновики | Папка «Черновики» + композер сохраняет и восстанавливает черновик | Композер **не сохраняет** черновик — набранное теряется (`MailComposeViewModel`) | Ф | **P1** | open |
| 2 | Композер «⋯» → «Текстовые шаблоны» | Вставка шаблона/подписи в тело письма | Текстовые блоки только в настройках (`MailRepository`), в композер не вставляются | Ф | P2 | open |
| 3 | Композер «⋯» → «Умный подборщик» (smart picker) | Есть (вставка ссылок/упоминаний/виджетов) | Нет | Ф | P3 | open |
| 4 | Меню письма → «Ответить встречей» | Создание ответа-приглашения (iMIP) | Нет (есть смежное «Создать событие» `MailComponents.kt:1254`) | Ф | P3 | open |
| 5 | Меню письма → «Скачать письмо» (.eml) | Выгрузка исходника письма | Нет | Ф | P3 | open |
| 6 | Меню письма → «Редактировать как новое сообщение» | Открывает композер с телом письма | Нет | Ф | P3 | open |
| 7 | Действие «Архивировать» над письмом | Отдельная кнопка «в архив» | Нет отдельного пункта; архив только через «Переместить» (`MailComponents.kt:1259`) | Ф | P3 | open |
## Дизайн / вёрстка
| # | Элемент | В вебе (forbion) | В нативе | Тип | Приор. | Статус |
|---|---|---|---|---|---|---|
| 8 | Подписи виртуальных папок | «Непрочитанное», «Избранные» | «Непрочитанные», «Избранное» (`MailRepository.kt:672-673`) | Д | P2 | open |
| 9 | Порядок папок | Приоритетные→Входящие→Непрочитанное→Избранные→Черновики→Отправленные→Спам→Корзина | Входящие→Непрочитанные→Избранное→Отправленные→Черновики→Архив→Корзина→Спам (`MailRepository.kt:671-680`) | Д | P2 | open |
| 10 | Быстрые действия строки списка | Inline-иконки при наведении (✉/🌿/🗑/⋯) | Свайп справа-налево (`f7SwipeFromRightToDismiss`) | Д | P3 | **device?** |
## Требуют device-сверки (карта live не покрыла)
| # | Что | Причина | Статус |
|---|---|---|---|
| A | Экран открытого письма/треда: панель действий (Ответить/Переслать/Архив/Спам/Корзина/Удалить), меты, вложения, HTML-тело | live не проходился — открытие непрочитанного = запись `\Seen`; структура достроена по `MessageView.vue`+макетам | device? |
| B | Модалка «Параметры эл. почты» по подразделам (native — 6 разделов) | JS-модалка, live не раскрыта по пунктам | device? |
| C | Диалоги «Изменить метки» / «Переместить» / «Запланированная отправка» — состав форм | live не раскрывались (пишущие) | device? |
| D | Действия «⋯» по папке и по аккаунту в шторке навигации | live не раскрывались | device? |
| E | Проверить #10: hover-иконки vs свайп — не артефакт ли desktop-web на узком экране | нужен реальный мобильный web/устройство | device? |
## Примечание по методу
- Пункт #10 помечен `device?`: наблюдённые inline-hover-действия могут быть поведением
desktop-web в узком вьюпорте, а не мобильным паттерном — до проверки на реальном мобильном
не тащить в реализацию.
- Метрику ≤2% (mail/081 п.2) считаю по чек-листу «элементы+функции на экран» — жду формулу лида.
+174
View File
@@ -0,0 +1,174 @@
# Карта: Почта
Снята по **мобильной веб-версии** forbion.f7cloud.ru (вьюпорт ~400px, плавающая нижняя панель —
мобильная тема), аккаунт `cloude` / почтовый ящик `cloude@forbion.ru`, **строго read-only**
(mail/073). Живой UI — полный **Nextcloud Mail 5.7.15**.
**Источники узлов:** `[live]` — пройдено в браузере; `[src]` — достроено по читаемому исходнику
`design-reference/forbion-apps/f7mail/src` (форк f7mail) + nextcloud/mail 5.7.15, т.к. узел
требует записи и live не проходился; `[mockup]` — сверено с `design/screens/mail/*.png`.
> ⚠️ **Ограничение read-only для Почты.** В NC Mail пометкой `\Seen` является само **открытие**
> непрочитанного письма — это запись на сервер. У `cloude` все 3 письма во «Входящих» —
> непрочитанные, «Отправленные»/«Черновики» пусты, прочитанных писем нет. Поэтому **экран
> открытого письма live не проходился**; его дерево дано по `[src]`/`[mockup]` и помечено
> `не-пройдено-live: \Seen`. Composer открывался пустым и закрывался без ввода (черновик не
> создавался). Ни одно пишущее действие (отправка/удаление/спам/перемещение/пометки) не нажималось.
---
## Вход
Нижняя плавающая панель оболочки → иконка «конверт» (раздел «Почта», активна) → открывается
**Список писем** текущей папки (по умолчанию «Приоритетные входящие», `/apps/mail/box/priority`).
Оболочку панели/шторки описывает `shell.md` (зона лида).
---
## Экран: Список писем (папка) `[live]`
URL: `/apps/mail/box/<id>` (напр. priority, 517=Входящие).
**Шапка:**
- Поле «Искать в папке» → фокус строки поиска в текущей папке.
- Иконка-слайдеры (справа в поле) → открывает модалку **«Параметры поиска»** (расширенный поиск).
- Круглая зелёная «+» → открывает **Композер** (сворачивается в плашку «Сообщение без названия»).
**Тело:** вертикальный список карточек-писем. Пустая папка → центр-заглушка «**В этой папке нет
сообщений**» + логотип F7 `[live]` (наблюдалось в «Отправленных»).
**Карточка письма (строка):**
- Аватар/инициалы отправителя (цветной кружок).
- Имя отправителя (жирным) + строка-превью темы/начала письма.
- Справа: относительное время («1:00», «вс») + **зелёная точка** = непрочитано.
- Тап по карточке → **Экран открытого письма/треда** (`/box/<id>/thread/<tid>`).
⚠️ помечает `\Seen` — live не нажималось.
**Быстрые действия строки (появляются при наведении/выборе)** `[live]`:
- ✉ конверт → пометить прочитанным/непрочитанным *(запись)*.
- 🌿 лист → важное/снять важность *(запись)*.
- 🗑 корзина → удалить ветку *(запись)*.
- ⋯ → контекстное меню (ниже).
### Контекстное меню письма (⋯ «Больше действий») `[live]`
Верхний ряд иконок: **В избранное** (звезда) · **Прочитать** (конверт) · **Снять важность** (лист).
Далее пункты:
- 🕐 `<дата и время письма>` (инфо-строка, напр. «13 июля 2026 г., 1:00»).
- ⓘ **Пометить как спам** *(запись)*.
- 🏷 **Изменить метки** → диалог выбора/назначения меток *(запись при сохранении)*.
- ⏰ **Запланированная отправка** *(в native — открывает composer-ответ с выбором времени)*.
- **Переместить ветку** → выбор папки назначения *(запись)*.
- 🗑 **Удалить ветку** *(запись)*.
- ⋯ **Больше действий** → подменю:
- **Редактировать как новое сообщение** → Композер с телом письма.
- **Ответить встречей** → создание события-приглашения (CalDAV).
- **Создать задачу** → создание задачи из письма (Tasks).
- **Скачать письмо** → выгрузка .eml.
---
## Экран: Параметры поиска (расширенный поиск) `[live]`
Открывается иконкой-слайдерами в поле поиска. Модалка «**Параметры поиска**», крестик ✕ (закрыть).
**Поля:**
- **Тема** (строка).
- **Тело** — «Найти» (строка).
- **Дата**: «Дата начала» и «Дата окончания» — два пикера даты (иконка календаря).
- **От** — «Выберите отправителей» (мультиселект).
- **Кому** — «Выберите получателей».
- **Коп** — получатели CC.
- **СкрКоп** — получатели BCC.
- **Метки** — «Выбор меток».
- **Отмечено как** (чекбоксы): Важное · В избранное · Непрочитанное · Имеет вложения ·
Упоминания меня.
**Низ:** «Очистить» (сброс) · «**Поиск**» (зелёная).
---
## Экран: Композер (новое сообщение) `[live]`
Открытие: «+» в шапке списка. Сначала **свёрнутая плашка** снизу «Сообщение без названия»
с иконками ⤢ (развернуть) и ✕ (закрыть/отменить). Развёрнутый вид — полноэкранная форма:
**Шапка:** заголовок «Новое сообщение», под ним отправитель «cloude <cloude@forbion.ru>»;
справа — («свернуть») и ✕ («закрыть»).
**Поля:**
- **Кому:** с шевроном-раскрытием → раскрывает **Копия:** (Cc) и **Ск:** (Bcc).
- **Тема сообщения**.
- **Текст** (тело, шеврон сворачивания); в правом нижнем углу тела — 📎 **скрепка** (вложения).
**Низ-панель:**
- **≡A** → панель форматирования текста (rich-text: жирный/курсив/списки и т.п.) `[src]`.
- **⋯** → меню: **Умный подборщик** · **Текстовые шаблоны** · **Отправить позже** ·
**Запросить подтверждение прочтения**.
- **▷ Отправить** (зелёная, неактивна при пустых получателях) *(запись — не нажималось)*.
расхождение: native-композер «⋯» содержит только «Отправить позже» + «Запросить подтверждение
прочтения» (`MailComposeScreen.kt:758,767`) — **нет «Умного подборщика» и «Текстовых шаблонов»**
(см. Расхождения п.2–3). Запланированная отправка, подтверждение прочтения, вложения, rich-text
(≡A) — совпадают.
---
## Экран: Открытое письмо / тред `[src] [mockup]` — не-пройдено-live: \Seen
URL: `/apps/mail/box/<id>/thread/<tid>`. Структура по `MessageView.vue` + макетам
`Почта _ Окно сообщения*.png`, `Сообщение с перепиской *.png`:
**Шапка:** тема письма (заголовок h2). Для локального (черновик/исходящее) — метка статуса.
**Панель действий над письмом:**
- Обычное письмо: **Ответить** · **Переслать** · **В избранное** (toggle) · **Отметить
непрочитанным** (если `\Seen`) · **Архивировать** · **В спам** · **В корзину** · **Удалить**.
- Черновик/исходящее: **Отправить** (если можно) · **Изменить черновик** · **Удалить**.
**Мета-строки:** Кому (to), Копия (cc), «Отправить в» (sendAt — для запланированных).
**Перемещение:** выпадающий выбор папки + кнопка «Переместить».
**Вложения:** список карточек вложений (если есть).
**Тело:** HTML-письмо в WebView-контейнере, либо текстовое в Compose (в native).
**Тред:** несколько писем переписки — сворачиваемые карточки (закрытые/открытое), см.
`Сообщение с перепиской закрытые.png` / `... открытое.png`.
**Плавающая кнопка «↩ Ответить»** (native `MailReplyFab`) — сверено ранее, `[mockup]`.
---
## Навигация папок (шторка «Открыть навигацию») `[live]`
Кнопка-гамбургер почты → выезжает список папок ящика. Сверху: «**Создать сообщение**» и
«**Обновить**». Далее:
- **Приоритетные входящие** (`/box/priority`).
- Аккаунт **cloude@forbion.ru** + кнопка «**Действия**» (⋯ по аккаунту).
- **Входящие** (`/box/517`) · **Непрочитанное** (`/box/unread/517`) · **Избранные**
(`/box/starred/517`) · **Черновики** (`/box/516`) · **Отправленные** (`/box/515`) ·
**Спам** (`/box/513`) · **Корзина** (`/box/514`).
- У каждой папки — кнопка «**Действия**» (⋯: переименование/очистка/подписка и т.п. `[src]`).
---
## Экран: Параметры эл. почты (настройки) `[live] вход` / `[src] глубина`
Точка входа: пункт «**Параметры эл. почты**» (в меню настроек шапки; JS-модалка, `href="#"`).
Глубину подтверждает native `MailSettingsScreens.kt` + макеты `Параметры электронной почты.png`,
`Параметры учётной записи.png`:
- **Основные** (общие параметры почты).
- **Учётные записи** (аккаунты; форма `AccountForm.vue` `[src]`).
- **Внешний вид**.
- **Текстовые шаблоны / подписи**.
- **Конфиденциальность**.
- **Безопасность**.
- **Фильтры** (sieve) — `FiltersSettingsView.vue` `[src]`; в native — общий официальный API
`/apps/mail/api/filter` (список/вкл/выкл/удаление/правка простых; сложные — долг v1).
расхождение(?): live-модалку «Параметры эл. почты» не раскрывал по пунктам (JS-диалог) — при
сверке на устройстве пройти каждый подраздел и сверить состав с native.
---
## Пустые/особые состояния `[live]`
- Пустая папка: «В этой папке нет сообщений» + логотип F7 по центру.
- Свёрнутый композер: плашка «Сообщение без названия» (⤢ / ✕) поверх списка.
---
## Расхождения с нативным `feature/mail`
Полный список расхождений (веб ↔ натив) вынесен в **[`mail.diff.md`](mail.diff.md)** — формат
итерации сверки по mail/081 (функция/элемент, тип Ф/Д, приоритет, статус). Кратко:
нативный клиент функционально зрелый (расширенный поиск, подтверждение прочтения,
важность/спам/метки/задача/событие/запланированная отправка — совпадают). Пробелы:
- **P1 функц.:** черновики не сохраняются из композера.
- **P2:** «Текстовые шаблоны» в композере; подписи и порядок виртуальных папок.
- **P3:** «Умный подборщик», «Ответить встречей», «Скачать письмо», «Редактировать как новое»,
отдельное «Архивировать»; паттерн быстрых действий строки (hover vs свайп — device?).
- **device?:** экран открытого письма, модалка настроек, диалоги меток/перемещения — live не
проходились (read-only / `\Seen`), см. `mail.diff.md` раздел «Требуют device-сверки».
+134
View File
@@ -0,0 +1,134 @@
# Diff: Заметки (Notes) — итерация 1 (2026-07-13)
Ff_app: **0.0%** · Fd_app: **0.0%** (среднее по 6 экранам) · n-a: 0
> ⚠️ **Особый случай: нативного модуля `feature/notes` ЕЩЁ НЕТ.** Он не заведён в
> `settings.gradle`/`app` — скелет создаёт лид по пингу «готов к регистрации» (mail/071 п.2,
> подтверждено в 093). Поэтому **все** функции/элементы веба — `open` (в нативе ноль), а
> счётчики честно 0%. Diff = полный бэклог на реализацию. **Код правок начну сразу, как лид
> заведёт скелет** — пинг «готов к регистрации» отправлен в ответе 093. До скелета коммитить в
> `feature/notes/**` физически некуда.
Сверка веб (`nextcloud/notes v5.0.0` + тема forbion) ↔ натив (**отсутствует**). Карта-источник:
[`notes.md`](notes.md) (снята из кода + визуально сверена на forbion, Browser 1, read-only).
Метки: `[Ф|Д]` (функционал/дизайн) · `[open|done|n-a]`. Подход к реализации согласован —
Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
Приоритеты предлагаю в конце (P1→P3); финальную приоритизацию — за лидом.
---
## Экран 1. Список заметок (главный) — Ff 0% · Fd 0%
**Ф (10, open 10):**
- [Ф][open] кнопка «Новая заметка» (создать + открыть редактор) — P1
- [Ф][open] поле «Искать заметки» (фильтр по заголовку) + очистка — P2
- [Ф][open] строка-заметка, тап → открыть редактор — P1
- [Ф][open] индикатор «расшарена» на строке — P3
- [Ф][open] меню строки «⋯» (см. Экран 2) — P1
- [Ф][open] группировка по таймслотам (сегодня/недавние/…) — P2
- [Ф][open] группировка по категориям (капшены) — P2
- [Ф][open] сортировка «избранное вверх» + по дате изменения — P2
- [Ф][open] пустое состояние Welcome (текст + «Новая заметка» + «создать образец») — P2
- [Ф][open] «Найти во всех категориях» (сброс фильтра при пустом поиске в категории) — P3
- (состояние загрузки «Загрузка …» — Д)
**Д (6, open 6):** [Д][open] композиция экрана-списка · [Д][open] мобильная вёрстка по
`_mobile-notes.css` (`content-list__search`, перенос) · [Д][open] порядок элементов шапки ·
[Д][open] типографика (Raleway, тема) · [Д][open] иконки (звезда избранного, share-индикатор) ·
[Д][open] состояние загрузки/пустое
## Экран 2. Меню заметки «⋯» (всплывающее, mail/087) — Ff 0% · Fd 0%
Полный состав из кода (`NoteItem` #actions), порядок сверен визуально:
**Ф (6, open 6):**
- [Ф][open] «Добавить/Удалить из избранного» (`PUT /favorite`) — P1
- [Ф][open] «Поделиться» → сайдбар шаринга (NC sharing) — P3
- [Ф][open] «<Категория>» / «Изменить категорию» (мультиселект + создать новую, `PUT /category`) — P2
- [Ф][open] «Переименовать» (инлайн, `PUT /title`) — P2
- [Ф][open] «Удалить заметку» (`DELETE`) — P1
- [Ф][open] отмена удаления (`POST /notes/undo`) — P2
**Д (3, open 3):** [Д][open] состав/порядок пунктов меню · [Д][open] иконки пунктов ·
[Д][open] инлайн-поля (переименование/категория)
## Экран 3. Редактор заметки (rich / plain / preview) — Ff 0% · Fd 0%
**Ф (10, open 10):**
- [Ф][open] rich-редактор (@nextcloud/text) — P1
- [Ф][open] панель форматирования: жирный/курсив/цитата/моно/очистить/список/нумерованный/
заголовок/крупный заголовок/ссылка (всплывающая тулбар-панель, mail/087) — P2
- [Ф][open] plain-режим — P2
- [Ф][open] тумблер «Просмотр»/«Правка» (preview⇄edit) — P2
- [Ф][open] тумблер «Полный экран» — P3
- [Ф][open] автосохранение с `If-Match: "<etag>"` (`PUT /notes/{id}`) — P1
- [Ф][open] автозаголовок из первой строки (`PUT /autotitle`) — P2
- [Ф][open] состояние read-only заметки (индикатор, блок правки) — P2
- [Ф][open] «Сохранение не удалось. Нажмите, чтобы повторить» (ручной ретрай) — P2
- [Ф][open] плейсхолдеры «Write …» / «Empty note» — P3
**Д (5, open 5):** [Д][open] вёрстка редактора · [Д][open] перенос тулбара форматирования на
мобильном (`.text-menubar__entries` flex-wrap) · [Д][open] типографика тела · [Д][open] меню
действий редактора (preview/fullscreen) · [Д][open] индикатор read-only
## Экран 4. Диалог конфликта версий — Ff 0% · Fd 0%
**Ф (3, open 3):**
- [Ф][open] детект конфликта (сервер вернул конфликт на `If-Match`) — P1
- [Ф][open] «Использовать версию с сервера» — P1
- [Ф][open] «Использовать текущую версию» — P1
- (⚠ обязателен — НЕ молчаливая перезапись, согласовано mail/071 п.2)
**Д (1, open 1):** [Д][open] вёрстка диалога конфликта
## Экран 5. Навигация категорий (шторка) — Ff 0% · Fd 0%
Всплывающие/контекстные меню категории (mail/087): «Новая категория», «⋯»-действия категории.
**Ф (8, open 8):**
- [Ф][open] «Новая категория» (создать; drag заметки → назначить) — P2
- [Ф][open] «Все заметки» + счётчик (сброс фильтра) — P1
- [Ф][open] «Без категории» + счётчик — P2
- [Ф][open] список категорий + счётчики, фильтрация по тапу — P2
- [Ф][open] «Переименовать категорию» (инлайн) — P3
- [Ф][open] «Удалить категорию» + диалог подтверждения — P3
- [Ф][open] вложенные подкатегории (`parent/child`) — P3
- [Ф][open] вход «Настройки заметок» (шестерёнка) — P2
**Д (4, open 4):** [Д][open] композиция шторки навигации · [Д][open] иконки папок (тема — серая
`folder-gray.svg`) · [Д][open] счётчики-бейджи · [Д][open] порядок (Все/Категории)
## Экран 6. Модалка «Настройки заметок» — Ff 0% · Fd 0%
**Ф (5, open 5):**
- [Ф][open] «Отображение»: Форматированный текст / Простой текст / Просмотр — P2
- [Ф][open] «Расширение файла»: .md / .txt / Свой (+ поле) — P3
- [Ф][open] «Папка заметок» (WebDAV picker) — P3
- [Ф][open] секция «Мобильные приложения» (Android / iPhone) — P3
- [Ф][open] секция «Комбинации клавиш» (в теме скрыта) — n-a? (уточнить — пока open) — P3
**Д (2, open 2):** [Д][open] полноэкранная модалка на мобильном (`_mobile-notes.css`) ·
[Д][open] вёрстка секций/переключателей
---
## Сводка
| Экран | Ф всего | Ф open | Ff% | Д всего | Д open | Fd% |
|---|---|---|---|---|---|---|
| 1. Список | 10 | 10 | 0% | 6 | 6 | 0% |
| 2. Меню «⋯» | 6 | 6 | 0% | 3 | 3 | 0% |
| 3. Редактор | 10 | 10 | 0% | 5 | 5 | 0% |
| 4. Конфликт | 3 | 3 | 0% | 1 | 1 | 0% |
| 5. Категории | 8 | 8 | 0% | 4 | 4 | 0% |
| 6. Настройки | 5 | 5 | 0% | 2 | 2 | 0% |
| **Итого** | **42** | **42** | **0%** | **21** | **21** | **0%** |
**Ff_app = 0.0% · Fd_app = 0.0%** (натив пуст — модуль не создан).
## Предлагаемый порядок реализации (план 1→2→3, mail/071)
- **P1 — read-only ядро:** список заметок (открыть/просмотр), rich-редактор, автосейв `If-Match`,
удаление, избранное, «Все заметки», диалог конфликта. (= итерация 1 плана)
- **P2 — запись/организация:** создание, переименование, категории (создать/сменить/фильтр),
автозаголовок, поиск, группировка, настройки отображения. (= итерация 2)
- **P3 — добивка:** шаринг, подкатегории, удаление/переименование категорий, полноэкранный
режим, расширение файла/папка, попиксельная вёрстка по `_mobile-notes.css`. (= итерация 3)
## Блокеры
1. **Скелет модуля** — заводит лид (mail/071/093); до него код не пишу. Пинг «готов к
регистрации» — в ответе 093.
2. **NC-апп `notes` на glb** — установлен ли (для интеграционной проверки записи); лид спросил
в PPS 069. До ответа write-путь — на unit-тестах (`core:network`), не на forbion (read-only, mail/073).
3. Повторный (подтверждающий) прогон для гейта — после реализации P1, на устройстве/`bmob-*`.
+172
View File
@@ -0,0 +1,172 @@
# Карта: Заметки
Приложение «Заметки» (`/apps/notes/`) на forbion.f7cloud.ru. Структура кнопок/меню снята
**из кода** (метод mail/078): апстрим `nextcloud/notes` **v5.0.0** (`src/**/*.vue` — точная
версия с forbion, канон README) + мобильная тема forbion
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-notes.css`,
`css/pages/app-notes/`). Живой мобильный web (Browser 1, `cloude`, mail/079) — только для
визуальной сверки «как выглядит/порядок»; **строго read-only** (mail/073): ничего не
создаётся/не меняется/не удаляется. Приватность: реальные заголовки/тексты заметок не
фиксируются — только структура UI и дерево переходов.
> Статус визуальной сверки на живом forbion (Browser 1 `c9284989`, `cloude`, mail/079,
> техника same-origin iframe 390px): **выполнена частично, 2026-07-12.** Мобильная вёрстка
> подтверждена (`header__mobile`, `header__mobile-bottom`). **Подтверждено на живом сервере:**
> пустое состояние Welcome (тексты 1:1 с кодом), кнопка «Новая заметка», поле поиска, «Создайте
> образец заметки с помощью Markdown», навигация «Все заметки» (со счётчиком) / капшен
> «Категории» / «Новая категория», «Меню настроек» (модалка открывается, секция «Мобильные
> приложения» — Android / iPhone and iPad). **НЕ наблюдаемо read-only:** список строк-заметок,
> меню заметки (⋮), редактор (rich/plain/preview), диалог конфликта, детальные секции настроек
> «Общее» — у `cloude` заметок НЕТ, а создание/открытие = запись (запрещено mail/073). Эти узлы
> остаются из кода (v5.0.0) — помечены `код-only` ниже. NB: при первом входе поверх приложения
> всплывает глобальный онбординг-попап «Добро пожаловать в F7cloud!» (не notes-специфичный, не
> закрывал — dismiss может писать флаг на сервер).
## Компоненты-источники (nextcloud/notes v5.0.0)
`App.vue` (шелл: навигация категорий + настройки) · `NotesView.vue` (список + поиск + «Новая
заметка») · `NotesList.vue`/`NoteItem.vue` (строка заметки + меню действий) ·
`CategoriesList.vue` (навигация: Все заметки / Категории) · `Note.vue``NoteRich.vue`
(rich-редактор @nextcloud/text) / `NotePlain.vue` (простой/preview) · `AppSettings.vue`
(модалка настроек) · `Welcome.vue` (пустое состояние) · `ConflictSolution.vue` (конфликт
версий) · `NoteShareSidebar.vue` (шаринг).
## Вход
Шторка приложений (по «трём полоскам») → «Заметки», или прямой URL `/apps/notes/`.
Оболочку (нижняя панель, шторка, бургер) описывает `shell.md` (зона лида) — здесь только
специфика приложения. На мобильном (узкий вьюпорт) сначала показывается **список заметок**;
выбор заметки открывает **редактор** поверх (`NcAppContent` show-details), возврат — «назад».
## Экран: Список заметок (главный)
**Шапка списка (`content-list__search`) — ✔ кнопка и поле подтверждены на forbion:**
- Кнопка «**Новая заметка**» (primary, иконка +) → создаёт заметку и открывает редактор
(write — на forbion НЕ нажимаю).
- Поле «**Искать заметки**» (`Search for notes`) → фильтрация списка по заголовку по мере
ввода; крестик (trailing) очищает поиск.
**Тело:** список строк-заметок, сгруппированный. Группировка (`NotesView` groupedNotes):
- если выбраны «Все заметки» и настроен режим — по **временным слотам** (заголовки-капшены
через `NotesCaption`: сегодня/недавние/… — таймслоты) и/или по **категориям**;
- избранные сортируются выше (favorite → вверх), затем по дате изменения.
**Строка заметки (`NoteItem` = `NcListItem`):**
- Тап по строке → открыть заметку в редакторе.
- Индикатор «расшарена» (значок ShareVariant, если у заметки есть шаринг).
- Кнопка «**действия**» (⋮, меню строки) → см. «Меню заметки».
**Пустое состояние (нет заметок / `Welcome.vue`) — ✔ подтверждено на forbion (`cloude`,
route `/apps/notes/welcome`):** заголовок «Заметки» + «Начните писать заметку, нажав «Новая
заметка».» + кнопка «**Новая заметка**»; подсказки: «Записывайте свои мысли, ни на что не
отвлекаясь.», «Поддержка языка разметки Markdown для оформления текста;», кнопка «**Создайте
образец заметки с помощью Markdown**» (`CreateSampleButton` — write, не нажимаю), «Поддержка
категорий;».
**Состояние загрузки:** «Загрузка …» (`Loading …`). **Пустой поиск в категории:** кнопка
«**Найти во всех категориях**» (сбрасывает выбранную категорию).
## Меню заметки (⋮ в строке — `NoteItem` #actions) — `код-only`
(У `cloude` заметок нет → строку и её меню на forbion read-only не наблюдал; из кода v5.0.0.)
Порядок пунктов:
- «**Добавить в избранное**» / «**Удалить из избранного**» (тумблер, звезда) → `PUT /favorite`.
- «**Поделиться**» (Share) → открывает сайдбар шаринга (`NoteShareSidebar`, стандартный
NC-шэринг). *(состав панели — общий NC sharing; детально не раскрывал — визуал(?))*
- «**<Категория>**» / «**Изменить категорию**» → инлайн-выбор категории (мультиселект с
возможностью ввести новую, taggable) → `PUT /category`.
- «**Переименовать**» → инлайн-поле ввода нового заголовка → `PUT /title`.
- —— разделитель ——
- «**Удалить заметку**» (если не read-only) → удаление (`DELETE`) с возможностью отмены
(`POST /notes/undo`). (write — не выполняю на forbion.)
## Экран: Редактор заметки — `код-only`
(Открытие/создание заметки = запись → на forbion read-only не наблюдал; из кода v5.0.0.)
Открывается тапом по заметке или «Новая заметка». Режим определяется настройкой Display
(rich / plain / preview, см. Настройки).
**Заголовок:** формируется автоматически из первой строки (autotitle, `PUT /autotitle`);
пустая заметка → «Новая заметка».
**Rich-режим (`NoteRich` → редактор @nextcloud/text):**
- **Панель форматирования** (`.text-menubar` — в теме `_mobile-notes.css` она переносится на
мобильном: `flex-wrap: wrap`). Инструменты (из списка горячих клавиш `AppSettings`):
жирный, курсив, цитата, моноширинный, очистить стиль, список, нумерованный список,
заголовок / крупный заголовок, вставить ссылку. *(точный набор иконок на узком экране —
визуал(?))*
- Тело — редактируемый markdown-контент; автосохранение (debounce) → `PUT /notes/{id}`
с заголовком `If-Match: "<etag>"`.
**Plain-режим (`NotePlain`) — меню действий редактора (`NcActions`):**
- «**Просмотр**» / «**Правка**» (тумблер preview⇄edit, подсказка «CTRL + /»).
- «**Полный экран**» / «**Выйти из полноэкранного режима**» (тумблер).
- Плейсхолдер пустого тела: «Write …» (или «Empty note» в preview).
**Read-only заметка:** пункт-индикатор «**Заметка только для чтения. Вы не можете её
изменить.**» (`PencilOffOutline`), редактирование заблокировано.
**Ошибка сохранения:** пункт «**Сохранение не удалось. Нажмите, чтобы повторить.**»
(`onManualSave`) — ручной повтор `PUT`.
## Диалог: Конфликт версий (`ConflictSolution`) — `код-only`
Возникает, если заметка изменена в другой сессии (сервер вернул конфликт на `If-Match`):
- Текст: «Заметка была изменена в другой сессии. Выберите, какую версию сохранить.»
- Кнопка «**Использовать версию с сервера**».
- Кнопка «**Использовать текущую версию**».
> Прямое подтверждение моего плана (mail/071 п.2): конфликт разрешается ЯВНЫМ выбором, а не
> молчаливой перезаписью. Нативный `NoteEditorScreen` обязан воспроизвести этот диалог.
## Навигация: Категории (левая шторка — `App.vue` + `CategoriesList`)
На мобильном — за «бургером»/шторкой навигации приложения (не нижняя панель оболочки).
✔ На forbion подтверждены: «Все заметки» (со счётчиком `0`), капшен «Категории», «Новая
категория», кнопка «Меню настроек». Список конкретных категорий пуст (у `cloude` нет заметок).
- «**Новая категория**» (`NcAppNavigationNew`, иконка папка+) → создание категории
(drag-n-drop заметки на неё тоже вешает категорию). (write — не выполняю.)
- «**Все заметки**» + счётчик (bubble) → сброс фильтра категории, весь список.
- Капшен «**Категории**», далее список категорий:
- «**Без категории**» (uncategorized) + счётчик.
- Каждая категория: название (иконка папки, в теме заменена на серую
`folder-gray.svg`) + счётчик; действия: «**Переименовать категорию**» (инлайн-правка),
«**Удалить категорию**» → подтверждение (диалог «Удалить категорию» / «Удалить» /
«Отмена»). (write — не выполняю.)
- Подкатегории: имя вида `parent/child` разворачивается вложенно.
- Внизу навигации: «**Настройки заметок**» (шестерёнка) → модалка настроек.
## Модалка: Настройки заметок (`AppSettings`)
На мобильном тема раскрывает её на весь экран (`_mobile-notes.css`: modal-container height
100%, скрыт заголовок modal-header). ✔ На forbion открыл модалку read-only и подтвердил секцию
«**Мобильные приложения**» (подписи «Android», «iPhone and iPad» = `HelpMobile.vue`). Секции
«Общее»/«Комбинации клавиш» детально не раскрывал (`код-only`, из v5.0.0). Секции:
- **Общее** (`General`):
- «**Отображение**» (Display): «Форматированный текст» (rich) / «Простой текст» (plain) /
«Просмотр» (preview).
- «**Расширение файла**» (для новых заметок): «.md» / «.txt» / «Свой» (+ поле «Своё
расширение файла»).
- «**Файлы**» → «**Папка заметок**» (`NcFormBoxButton`) → выбор папки хранения заметок
(WebDAV picker). (write-настройка — не меняю.)
- **Мобильные приложения** (`Mobile apps`) — ссылки/подсказки на приложения (в теме
секция клавиш-шорткатов и это скрыты частично — визуал(?)).
- **Комбинации клавиш** (`Shortcuts`) — справочный список (см. панель форматирования выше).
В теме forbion секция `keyboard-shortcuts` **скрыта** (`display:none`) — на forbion её,
вероятно, не видно. `визуал(?)`
## API-контур (для нативного модуля — подтверждает mail/071)
Базовый путь `apps/notes/api/v1` (в вебе — `apps/notes/...`):
- `GET /notes?pruneBefore=<ts>` — список (инкрементальный синк по времени; в нашем клиенте —
`exclude=content` для лёгкого списка + ETag-коллекция).
- `GET /notes/{id}` — заметка; `POST /notes` — создать; `PUT /notes/{id}` (тело `content`,
заголовок `If-Match: "<etag>"`) — сохранить; `DELETE /notes/{id}`; `POST /notes/undo`.
- `PUT /notes/{id}/favorite`, `/category`, `/title`, `/autotitle`;
`PATCH /notes/category` (переименовать), `DELETE /notes/category` (удалить категорию).
- Поля модели: `id, title, category, content, favorite, modified, etag, readonly`.
## Расхождения с нативным клиентом
Модуль `feature/notes` ещё **не создан** (скелет заводит лид по mail/071) — поэтому полный
список расхождений = весь функционал выше как первичный бэклог. Ключевые пункты к паритету
при реализации плана 1→2→3 (mail/071):
- Список: поиск по заголовку, группировка (таймслоты + категории), сортировка «избранное
вверх», индикатор шаринга, пустое/загрузочное состояния.
- Меню заметки: избранное, категория (с созданием новой), переименование, удаление с undo,
шаринг.
- Редактор: rich (панель форматирования) ↔ plain ↔ preview, полноэкранный режим, автосейв
с `If-Match`, автозаголовок, read-only состояние.
- **Диалог конфликта версий** (обязателен — не молчаливая перезапись).
- Категории: навигация «Все/Без категории/по категориям» со счётчиками, CRUD категорий,
вложенность `parent/child`.
- Настройки: режим отображения, расширение файла, папка заметок.
+136
View File
@@ -0,0 +1,136 @@
# Карта: Уведомления (forbion, мобильная веб-версия)
Снято 2026-07-12. Метод по mail/078: структура берётся ИЗ КОДА
(апстрим `nextcloud/notifications`, компоненты `NotificationsApp.vue` / `NotificationItem.vue` /
`ActionButton.vue` / `IconNotification.vue`; на forbion версия 5.0.0-dev.0 — компоненты стабильны
между версиями), живой мобильный web forbion (вьюпорт 390px, аккаунт `cloude`, строго read-only
по mail/073) — для визуальной сверки размещения и порядка. Живой аккаунт `cloude` на момент съёмки
имел **пустую ленту** (empty-state), поэтому структура элемента уведомления и действий снята с кода,
а не с экрана. Пишущих кнопок («Скрыть», «Скрыть все», action-кнопки) НЕ нажимал — dismiss и
выполнение действий это записи (DELETE / произвольный HTTP-метод), а аккаунт реальный рабочий.
Уведомления — НЕ отдельное приложение (нет пункта в шторке приложений и своего URL-роута), а
глобальный **дропдаун-лента в шапке** (компонент `NotificationsApp`, монтируется в `#notifications`).
## Вход
В мобильной вёрстке forbion иконка-триггер (колокольчик) живёт в **шторке приложений**, а не на
плавающей нижней панели. Путь:
- Нижняя панель шелла → `≡` («три полоски», `#open-header-menu-mobile`) открывает шторку
`app-menu--mobile`.
- В шторке — слот `#mobile-header-notifications` (класс `f7-mobile-menu-control-slot`,
`mobile-bottom-slot--overlay`) с кнопкой-колокольчиком (`.notifications-button`,
`aria-label="Уведомления"`, `aria-expanded`).
- Нижняя плавающая панель (общая, зона лида, для ориентира): `<`/`>` (сайдбар-навигация,
`#mobile-header-left-sidebar-button`) · конверт → Почта (`/apps/mail/`) · «карточки» → Задачи
(`/apps/tasks/`) · камера → Конференции (`/apps/spreed/`) · `≡` → шторка приложений.
## Триггер (колокольчик) — `IconNotification`
**Вид:** иконка «колокол». Индикатор состояния (`showDot`):
- точка-бейдж показывается, когда `notifications.length !== 0` **или** когда браузерные разрешения
ещё не запрошены (`webNotificationsGranted === null`);
- значок-предупреждение (`showWarning`) — когда push-уведомления задросселированы политикой
честного использования (`hasThrottledPushNotifications`).
**Действие:** тап → раскрывает дропдаун-ленту (`aria-expanded` false→true), эмит `@opened → onOpen`
(подгрузка/пометка ленты активной). Повторный тап или клик вне (кроме `.popover`) — закрывает.
## Экран: Лента уведомлений (дропдаун `NcHeaderMenu`, класс `header-menu`)
Контейнер `.notification-container` внутри `.header-menu__content`. Тайтл шапки в мобильной
теме — «Уведомления» (`.tooltip-header--notifications`). Два взаимоисключающих состояния:
### Состояние A — есть уведомления (`notifications.length > 0`) — СВЕРЕНО ЖИВЬЁМ у `cloude`
**Тело:** список `<ul class="notification-wrapper">` из элементов `NotificationItem` (см. ниже),
сверху вниз, новые (`notificationId > lastOpenMaxId`) помечены классом `notification--new`.
Если push задросселирован — первым нештатным элементом идёт баннер «Push notifications might be
unreliable» (fairUsePolicy, `key=-2016`).
**Низ:** блок `.dismiss-all` — кнопка **«Скрыть все уведомления»** (tertiary, во всю ширину,
иконка «×»). ⚠️ Пишущая: `onDismissAll``DELETE /ocs/v2.php/apps/notifications/api/v2/notifications`.
**Живая сверка 2026-07-12 (лента `cloude` = 3 уведомления `firstrunwizard`, скриншот 390px):**
- `расхождение вер.:` кнопка **«Скрыть все уведомления»** на forbion (5.0.0-dev.0) показывается
ВСЕГДА, в т.ч. при пустой ленте — в master-коде она под `v-if="notifications.length > 0"`.
Нативный клиент должен ориентироваться на поведение forbion (кнопка всегда), не на master.
- `расхождение вер.:` время в элементе отображается **относительно** («45 минут назад»,
«49 минут назад»), а не в формате «время + длинная дата» из master `NcDateTime`.
- Порядок в строке подтверждён: верхняя строка = [относительное время] … [× «Скрыть»];
ниже слева иконка app, справа subject (жирнее) и, если есть, message (серым, второй строкой).
- Наблюдённые элементы (все `data-app=firstrunwizard`, `objectType` user/app): у всех есть
время + «Скрыть» + иконка + ссылка (`full-subject-link`, не внешняя), rich-subject НЕТ,
action-кнопок НЕТ; у двух из трёх есть `message`. Примеры (приветственные, не приватные):
«Добавьте данные в свой профиль…», «Рекомендованное приложение: Формы», «…: Распознавание».
- `notification--new` в этой выборке не встретился (уведомления не новее `lastOpenMaxId`) —
подсветку нового сверить, когда придёт свежее.
### Состояние B — пусто (empty-state `NcEmptyContent`) — тоже наблюдалось у `cloude` (до догрузки ленты)
Три варианта текста (computed `emptyContentMessage`):
- `webNotificationsGranted === null`**«Запрос разрешений для показа уведомлений в браузере»**
(именно это видел у `cloude` — иконка колокола `bell-outline-icon`);
- задросселированы push → заголовок = subject fairUsePolicy + описание + кнопка «Contact
Nextcloud GmbH ↗» (внешняя ссылка), иконка `icon-alert-outline`;
- иначе → **«Нет уведомлений»** (`No notifications`). При переходе списка из >0 в 0 на ~5 сек
показывается анимация inbox-zero (галочка + конфетти), затем обычный колокол.
## Элемент: `NotificationItem` (`<li class="notification">`, из кода)
data-атрибуты: `data-id`, `data-timestamp`, `data-object-type`, `data-app`.
**Шапка элемента (`.notification-heading`):**
- время (`NcDateTime`, формат «время + длинная дата», `.notification-time`) — если есть timestamp;
- кнопка **«Скрыть»** (`.notification-dismiss-button`, tertiary, иконка «×», `aria-label="Dismiss"`).
⚠️ Пишущая: `onDismissNotification``DELETE …/api/v2/notifications/{id}`, затем `@remove` (убрать из ленты).
**Субъект (заголовок), три варианта рендера:**
- если `externalLink``<a target=_blank>` класс `full-subject-link external`, текст «{subject} ↗»;
- иначе если есть `link` (useLink) → `<a>` на этот линк;
- иначе — просто блок `.notification-subject` без ссылки.
Внутри: опциональная иконка (`img.notification-icon`); если есть `subjectRich` — богатый рендер
`NcRichText` с параметрами (см. Parameters ниже), иначе простой `.subject` с текстом subject.
**Сообщение (`.notification-message`, если есть `message`):** богатый (`messageRich` + autolink)
или простой текст; длинное сворачивается (`.message-container.collapsed` + градиент
`.notification-overflow`), тап по сообщению — разворот (`onClickMessage`).
**Действия (`.notification-actions`):**
- если `notification.actions[]` непусто — ряд `ActionButton` по каждому action;
- иначе, если есть `externalLink` — одна primary-кнопка «Contact Nextcloud GmbH ↗».
## Элемент: `ActionButton` (кнопка действия уведомления)
Поля action (из API): `label`, `link`, `type`, `primary`.
- `type === 'WEB'` (isWebLink) или `primary` → кнопка **primary**; для WEB это прямой `href` (ссылка).
- прочие типы (GET/POST/DELETE/PUT) → кнопка **secondary**.
**Клик (`onClickAction`):** эмит `notifications:action:execute` (приложение может отменить,
`cancelAction`); для WEB — просто переход по ссылке; иначе ⚠️ пишущий вызов
`axios({ method: action.type, url: action.link })`, при успехе — `@remove` (уходит из ленты) +
эмит `notifications:action:executed`. Ошибка → тост «Failed to perform action».
Пример типового уведомления с действиями — приглашение в Talk-беседу: subject + действия
«Присоединиться»/«Отклонить» и т.п. (состав задаёт серверное приложение-источник).
## Параметры rich-subject/message (`Components/Parameters/`)
`NcRichText` подставляет типизированные плейсхолдеры:
- `UserParameter` — пользователь (аватар + имя, кликабельно);
- `FileParameter` — файл (иконка + имя, ссылка на файл);
- `DefaultParameter` — прочие (просто текст).
`расхождение:` в нативном клиенте (`NotificationsRepository`/`NotificationsSheet`) сейчас берётся
только плоский `subject`/`message` (API v2), rich-параметры и иконка app не разворачиваются —
это мой бэклог (см. представление mail/070).
## API (из кода, все через OCS `apps/notifications/api/v2/notifications`)
- `GET …/notifications` — лента (в нативе уже есть, `NotificationsRepository.load`, limit=50);
- `DELETE …/notifications/{id}` — скрыть одно (в нативе есть `dismiss`);
- `DELETE …/notifications` — скрыть все (в нативе НЕТ — бэклог «отметить/скрыть все»);
- `{action.type} {action.link}` — выполнить действие уведомления (в нативе действий НЕТ — бэклог).
Обновление ленты — по ETag (`lastETag`) и, если доступен, через notify_push (`@nextcloud/notify_push`).
## Стили (тема forbion)
Отдельного `_mobile-notifications.css` в теме нет: на вебе лента — дроп в шапке
(`css/components/_header.css`), мобильное размещение колокольчика — слот
`#mobile-header-notifications` в `header__mobile-bottom`/шторке (тема
`css/pages/pages-mobile/_mobile-common.css`). Для нативной ленты держим общий карточный стиль
модулей (`_mobile-common.css` + токены designsystem) — согласовано с лидом (mail/070).
## Открытые вопросы (остаток после живой сверки 2026-07-12)
Закрыто живьём: размещение строки, относительное время, «Скрыть все» всегда видна, состав
простого уведомления (firstrunwizard) — см. «Состояние A». Осталось (нужны уведомления
конкретных типов, которых у `cloude` сейчас нет):
- (?) `notification--new`: подсветка/полоса для СВЕЖЕГО уведомления (в выборке все были старее
`lastOpenMaxId`) — сверить, когда придёт новое.
- (?) Точный порядок и подписи **action-кнопок** для источников с действиями (Talk-приглашение
«Присоединиться/Отклонить», дедлайны Deck/Календаря) — у firstrunwizard действий нет.
- (?) Сворачивание длинного `message` на ширине 390px (высота `.message-container.collapsed`,
градиент `.notification-overflow`, разворот по тапу) — у наблюдённых message короткие.
- (?) Богатый `subjectRich` (`NcRichText` с User/File-параметрами) — у firstrunwizard subject
плоский; сверить на уведомлении с упоминанием пользователя/файла.
+127
View File
@@ -0,0 +1,127 @@
# Diff: Конференции (Talk) — итерация 1 (2026-07-13)
Ff_app: **40.0%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
> Прогресс P1 (коммиты 59543a7, bbaaf7a): реализованы меню сообщения «Копировать», «Копировать
> ссылку», «Переслать в «Личные заметки»», «Напоминание» (пресеты) — `[done]` ниже
> (Ff экрана «Беседа» 38.1%→54.5%).
Сверка веб (карта `talk.md`, апстрим `spreed v22.0.12` + тема) ↔ натив (`feature/talk` Compose на
OCS /api/v4; звонки — `vendor/talk-android` v23). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве в след. проход — здесь Ф-функционал из кода; по
сайдбару, пройденному live (Browser 1/cloude, iframe 390px, read-only), внесены реальные подписи.
Натив-подписи ниже — из кода `TalkComponents.kt`/`TalkScreen.kt`/`TalkRoomInfoSheet.kt`.
## Экран 1: Список обсуждений — Ff 46.7% (8 open Ф / 15)
Всплывающее меню строки беседы (mail/087) — веб `Conversation actions` ↔ натив:
- [Ф][done] Поиск по списку · натив «Поиск» (`TalkComponents`).
- [Ф][open] Фильтр списка (Непрочитанные/Упоминания/Встречи) · натив есть «Непрочитанные»/«Фильтр»,
но без «Упоминания»/«Встречи» — частично.
- [Ф][done] «+» FAB создания · натив «Новый чат».
- [Ф][done] Открыть беседу (тап строки).
- [Ф][done] В избранное / Убрать из избранного · натив «В избранное»/«Убрать из избранного».
- [Ф][done] Пометить непрочитанным · натив «Пометить непрочитанным».
- [Ф][done] Уровень уведомлений (Все/@/Выкл) · натив в листе «О комнате» (Все/@/Выкл.).
- [Ф][open] **Отметить как прочитанное** (Mark as read) — в нативе нет отдельного действия.
- [Ф][open] **Архивировать / Разархивировать** (Archive) — нет.
- [Ф][open] **Покинуть обсуждение** (Leave conversation) — нет в меню списка.
- [Ф][open] **Удалить обсуждение** (Delete conversation) — `deleteMessage` есть, удаления комнаты из UI нет.
- [Ф][open] **Копировать ссылку** (Copy link) — нет.
- [Ф][open] **Скрыть текст сообщения** (Hide message text, приватность превью) — нет.
- [Ф][open] Метки **Важное / Чувствительное** (Important / Sensitive) — нет.
- [Ф][open] Пункты сайдбара: **Архивные / Треды / Ожидающие приглашения** — нет.
- [Д][—] Композиция строки (аватар/имя/превью/бейдж), зелёный FAB, поиск+фильтр — сверено live,
выглядит 1:1 (детальный Fd — на устройстве).
## Экран 2: Меню «+» (создание) — Ff 33.3% (4 open Ф / 6)
- [Ф][done] Создать обсуждение · натив «Новый чат» + «Создать группу» (личный/групповой/публичный).
- [Ф][done] Новая личная заметка · есть комната «Личные заметки».
- [Ф][open] **Начать встречу сейчас** (Start meeting now) — нет.
- [Ф][open] **Присоединиться к открытым обсуждениям** (Join open conversations) — нет.
- [Ф][open] **Проверить устройства** (Check devices / MediaSettings-превью) — нет (в нативе превью нет).
- [Ф][open] **Позвонить на телефонный номер** (Call a phone number) — нет.
- Мастер создания: [Ф][done] имя/тип/аватар; [Ф][open] описание, защита паролем, «Разрешить
гостям по ссылке» — в нативе минимальный (имя+тип). *(учтено в open выше как часть «создать».)*
## Экран 3: Беседа + композер + меню сообщения — Ff 54.5% (10 open Ф / 22)
Композер:
- [Ф][done] Отправить сообщение · «Отправить».
- [Ф][done] Эмодзи · «Смайлики».
- [Ф][done] Вложение/шаринг файла · «Вложение».
- [Ф][done] Голосовое сообщение · «Голосовое» + «Остановить запись».
- [Ф][open] **Умный подборщик** (Smart picker) — нет.
- [Ф][open] **Создать опрос** (Poll) — нет.
- [Ф][open] **Создать тред** (Create a thread) — нет (тредов в нативе нет).
- [Ф][open] **Тихая отправка** (Send without notification / silently) — нет.
Меню сообщения (mail/087) — веб `MessageButtonsBar` ↔ натив (`MessageActions`):
- [Ф][done] Ответить · «Ответить».
- [Ф][done] Изменить · «Изменить».
- [Ф][done] Удалить · «Удалить».
- [Ф][done] Реакция · набор эмодзи (реакции есть).
- [Ф][open] **Ответить лично** (Reply privately) — нет.
- [Ф][done] **Переслать в «Личные заметки»** — реализовано (59543a7); пересылка в произвольную
комнату (выбор комнаты) — ещё open, вынес отдельным пунктом ниже.
- [Ф][open] Переслать в произвольную комнату (выбор из списка) — нет (пока только note-to-self).
- [Ф][done] **Копировать сообщение** (Copy message → буфер) — реализовано (59543a7).
- [Ф][done] **Копировать ссылку** (Copy message link → буфер) — реализовано (59543a7).
- [Ф][done] **Напоминание** (Set reminder — сегодня/завтра/выходные/неделя) — реализовано (bbaaf7a);
«своё» время (custom picker) — ещё open (мелочь, при добивке).
- [Ф][open] **Отметить как непрочитанное** (per-message) — нет.
- [Ф][open] **Перейти к файлу / к треду** (Go to file / thread) — нет.
- [Ф][open] **Перевести** (Translate) — нет.
- [Ф][open] **Скачать файл** (Download file из сообщения) — частично (вложения открываются, явного «скачать» нет).
- [Ф][open] Напоминания/реакции доп. эмодзи (React with another emoji) — ограниченный набор.
- [Д][—] Пузырь/автор/подложка чата — фон из макета (v0.5.114), детальный Fd — на устройстве.
## Экран 4: Активный звонок — Ff 91.7% (1 open Ф / 12; звонки НЕ инициировались)
Реализация — `vendor/talk-android` v23 `CallActivity` (нативный WebRTC), покрывает панель звонка
из кода апстрима: [Ф][done] микрофон, видео, размытие/вирт.фон, шаринг экрана, реакция, поднять
руку, добавить участников, запись, breakout, полный экран, покинуть/завершить.
- [Ф][open] **Скачать список присутствия** (Download attendance list) — проверить наличие в vendor UI.
- [Д][n-a] Нагрузочная сверка звонка — запрещена (HPB боевой, mail/073/085); не тестируем.
> Не-пройдено-live (звонки не инициирую). Отдельный трек — контрактная сверка v23 ↔ f7_talk
> 22.0.12-f7.N + гашение EOL-баннера (mail/071).
## Экран 5: Детали беседы (правый сайдбар) — Ff 42.9% (4 open Ф / 7)
Веб-табы ↔ натив лист «О комнате» (`TalkRoomInfoSheet`):
- [Ф][done] Детали (тип, уровень уведомлений).
- [Ф][done] Участники (список + «Добавить участника»).
- [Ф][done] Поиск по сообщениям.
- [Ф][open] **Общие ресурсы** (Shared items — файлы/медиа/ссылки беседы) — нет.
- [Ф][open] **Треды** (Threads) — нет.
- [Ф][open] **Комнаты обсуждения** (Breakout rooms) — нет.
- [Ф][open] **Богатые действия участника** (промоут/права/лобби/бан/телефон) — в нативе только список.
## Экран 6: Настройки обсуждения — Ff 11.1% (8 open Ф / 9; 2 n-a)
Веб `ConversationSettingsDialog` секции ↔ натив (нет выделенного экрана, частично лист «О комнате»):
- [Ф][done] Базовая информация (тип/уведомления — в листе).
- [Ф][open] Модерация, Права, Встреча, Комнаты обсуждения, Живая транскрипция, Опасная зона
(архив/удаление/покинуть), Обзор настройки, Личное — нет.
- [Ф][n-a] **Matterbridge**, **Боты** — серверно-администраторские, мобильно-неприменимо
(обоснование: конфигурируются в вебе/сервере `f7_talk`, не мобильный сценарий).
## Экран 7: Личные настройки Talk — Ff 0% (8 open Ф / 8; 1 n-a)
Веб `SettingsDialog` ↔ натив (выделенного экрана нет):
- [Ф][open] Внешний вид/звуки, Компактный список, Устройства/Проверить, Размытие по умолчанию,
Пропуск предпросмотра, Приватность (чтение/набор), Папка вложений, Настройки уведомлений — нет.
- [Ф][n-a] Горячие клавиши (Shortcuts) — мобильно-неприменимо.
---
## Сводка и приоритеты (прошу приоритизацию у лида)
Крупнейшие функциональные дыры (кандидаты P1–P5):
1. **Меню сообщения**: Переслать, Копировать, Напоминание, Ответить лично, Перейти к файлу/треду, Перевод.
2. **Меню беседы (список)**: Архив, Покинуть, Удалить, Копировать ссылку, Отметить прочитанным.
3. **Композер**: опрос, умный подборщик, тихая отправка, тред.
4. **Детали**: Общие ресурсы (Shared items), богатые действия участника.
5. **Треды** (сквозная фича, затрагивает список/беседу/детали) и **личные настройки Talk**.
Вопросы к лиду (b-dev-mobile):
- Порядок P1–P5? Треды — целиком мой модуль или нужна навигация/core?
- «Начать встречу сейчас» и «Предстоящие встречи» тянут интеграцию с Календарём (CalDAV) —
кросс-модуль с b-mob-calendar? Веду я или согласуем общий вход?
- Экран 4 (звонок): подтвердить, что панель vendor v23 считаем `done` без live-нагрузки, и
выделить «список присутствия» как единственный open.
- Fd (дизайн): гоню отдельным проходом на устройстве после ревью-сборки — версию/сборку по гейту (084) за тобой.
**Живая write-сверка `bmob-*`** (mail/085): у меня write на forbion пока НЕ выполняю — по
CONVENTIONS §1 правки боевого хоста требуют явного разрешения владельца в рабочем чате роли;
запрошу подтверждение. Функциональные дыры выше выявлены из кода, live-запись для их фиксации не нужна.
+216
View File
@@ -0,0 +1,216 @@
# Карта: Конференции (Talk)
Приложение «Конференции» (`/apps/spreed/`) на forbion.f7cloud.ru. Структура кнопок/меню/переходов
снята **из кода** (метод mail/078): апстрим `nextcloud/spreed` **v22.0.12** (`src/**/*.vue` — точная
версия с forbion, совпадает с серверным форком `f7_talk 22.0.12-f7.N`) + мобильная тема forbion
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-spreed.css`, 4495 строк;
`css/pages/app-spreed/`). Натив-эталон для сверки — мой `vendor/talk-android` v23 + `feature/talk`.
Живой мобильный web (Browser 1, `cloude`, mail/079) — только визуальная сверка «как выглядит/порядок»;
**строго read-only** (mail/073): ничего не создаётся/не меняется/не удаляется, **звонки не
инициируются** (mail/073 п.4). Приватность: реальные имена собеседников/содержимое чатов не
фиксируются — только структура UI и дерево переходов.
> Статус визуальной сверки на живом forbion: **левый сайдбар (список обсуждений) пройден
> 2026-07-13** (Browser 1, `cloude`, same-origin iframe 390px, read-only — ни одной пишущей
> операции). Реальные русские подписи forbion внесены (`сверено:`). **Экран беседы/чата
> live НЕ проходился намеренно**: открытие комнаты выставляет read-marker на сервере (запись,
> аналог `\Seen` у почты — см. mail.md) → узлы «Беседа», «Звонок», «Детали беседы», меню
> сообщения достроены из кода апстрима, помечены `не-пройдено-live`.
## Компоненты-источники (nextcloud/spreed v22.0.12)
Левый сайдбар (список): `LeftSidebar/LeftSidebar.vue` (+ `ConversationsList/`,
`SearchConversationsResults/`, `OpenConversationsList/`, `InvitationHandler.vue`,
`CallPhoneDialog/`). Создание: `NewConversationDialog/` (`NewConversationDialog.vue`,
`NewConversationContactsPage.vue`, `NewConversationSetupPage.vue`). Топбар беседы/звонка:
`TopBar/TopBar.vue` (+ `CallButton.vue`, `CallTime.vue`, `TopBarMenu.vue`, `TopBarMediaControls.vue`,
`ReactionMenu.vue`, `TasksCounter.vue`). Чат: `ChatView.vue`, `MessagesList/` (+
`MessagesGroup/Message/…/MessageButtonsBar.vue` — меню сообщения). Композер: `NewMessage/`. Звонок:
`CallView/`, `MediaSettings/`. Правый сайдбар (детали): `RightSidebar/RightSidebar.vue`
(+ `Participants/`, `SharedItems/`, `Threads/`, `SearchMessages/`, `BreakoutRooms/`,
`LobbyStatus.vue`, `SipSettings.vue`). Настройки беседы: `ConversationSettings/ConversationSettingsDialog.vue`.
Личные настройки: `SettingsDialog/SettingsDialog.vue`. Пусто-состояние: `EmptyView.vue`.
## Вход
Нижняя плавающая панель оболочки → «Конференции», либо шторка приложений («три полоски»), либо
прямой URL `/apps/spreed/`. Оболочку (нижняя панель, шторка, бургер) описывает `shell.md` (зона
лида) — здесь только специфика приложения. На мобильном (узкий вьюпорт ~390px) тема
(`_mobile-spreed.css`) показывает **сначала левый сайдбар на весь экран** (список обсуждений);
выбор беседы разворачивает беседу на весь экран, правый сайдбар (детали) и модалки — тоже
`height:100%`, «назад» возвращает к списку.
## Экран: Список обсуждений (левый сайдбар — `LeftSidebar`)
Лендинг приложения на мобильном.
**Шапка:**
- Поле «**Поиск …**» (Search) → фильтрация списка + поиск людей/открытых обсуждений
(`SearchConversationsResults`, `OpenConversationsList`).
- Иконка-фильтр (ползунки, `Filter conversations by`) → меню фильтров: «**Непрочитанные**»
(Unread), «**Упоминания**» (Mentions), «**Встречи**» (Meetings/Meeting conversations),
«**Очистить фильтр**» (Clear filter/Clear filters). `сверено:` на forbion рядом с полем поиска.
- Зелёная кнопка «**+**» → меню создания (см. ниже). `сверено:` FAB зелёный, справа от поиска.
- (Над сайдбаром — общий NC-хедер оболочки: «Объединённый поиск», «Меню настроек»,
«Уведомления» — это `shell.md`, не Talk.)
**Тело:** список обсуждений (`ConversationsList``Conversation`). Каждая строка: аватар/иконка,
имя, превью последнего сообщения, бейдж непрочитанных, индикатор звонка «Call in progress».
`сверено:` на forbion у `cloude` видны «Личные заметки» (Note to self, создано системой),
«Давайте начнём!» (группа, бейдж 7), «Обновления приложения "Конференции"» (бейдж 57).
**Действия строки (свайп/⋯ — `Conversation actions`):**
- «**Отметить как прочитанное / непрочитанное**» (Mark as read / unread).
- «**Добавить в избранное / Убрать из избранного**» (Add to / Remove from favorites).
- «**Уведомления**» (Notifications) → подменю: «Уведомлять о звонках» (Notify about calls),
«@-упоминания» (@-mentions only), «Все сообщения» (All messages), «Выкл» (Off),
«Игнорировать "Не беспокоить"» (Ignore "Do not disturb").
- «**Настройки обсуждения**» (Conversation settings) → диалог настроек беседы (см. ниже).
- «**Скрыть текст сообщения**» (Hide message text — приватность превью).
- «**Архивировать / Разархивировать обсуждение**» (Archive / Unarchive conversation).
- «**Копировать ссылку**» (Copy link).
- «**Покинуть обсуждение**» (Leave conversation) → подтверждение «Do you really want to leave…»;
предупреждение «You need to promote a new moderator before you can leave».
- «**Удалить обсуждение**» (Delete conversation) → подтверждение «Do you really want to delete…».
- Метки: «Важное обсуждение» (Important), «Чувствительное обсуждение» (Sensitive conversation).
*(Все перечисленные — потенциально write; на live НЕ жму.)*
**Прочие пункты сайдбара (навигация/`TopBarMenu` списка):** «**Архивные обсуждения**»
(Archived conversations / «You have no archived conversations»), «**Обсуждения-встречи**»
(Meeting conversations), «**Треды**» (Threads / «No followed threads» / «Show more threads»),
«**Ожидающие приглашения**» (Pending invitations, `InvitationHandler`), «**Настройки приложения**»
(App settings) → личные настройки (см. «Настройки Talk»).
**Пусто-состояния:** «Обсуждения не найдены» (No conversations found), «Нет совпадений»
(No matches found), «У вас нет непрочитанных упоминаний/сообщений».
## Экран: Меню «+» (создание) — `NewConversationDialog` + пункты
Открывается зелёной «+». Пункты (`сверено:` порядок с live forbion):
- «**Начать встречу сейчас**» (Start meeting now) → создаёт беседу и сразу входит в звонок.
*(write — не жму.)*
- «**Создать новое обсуждение**» (Create a new conversation) → мастер создания (см. ниже).
- «**Присоединиться к открытым обсуждениям**» (Join open conversations) → список открытых
(`OpenConversationsList`) → «Присоединиться к обсуждению» (Join conversation).
- «**Проверить устройства**» (Check devices) → `MediaSettings` (превью камеры/микрофона,
выбор устройств, размытие фона) — read-only-превью, без входа в звонок.
- (Также доступно) «**Позвонить на телефонный номер**» (Call a phone number, `CallPhoneDialog`),
«**Новая личная заметка**» (New personal note).
### Подэкран: Мастер «Создать новое обсуждение» (`NewConversationDialog`)
Два шага:
1. **Contacts page** (`NewConversationContactsPage`): «**Найти участников или телефонные номера**»
(Search participants or phone numbers), «**Добавить участников**» (Add participants),
«**Добавить телефонный номер**» (Add a phone number).
2. **Setup page** (`NewConversationSetupPage`): поля «**Имя**» (Name / «Enter a name…»),
«**Описание**» (Description), «**Изображение**» (Picture — аватар беседы); переключатели
«**Видимость обсуждения**» (Conversation visibility), «**Разрешить гостям вход по ссылке**»
(Allow guests to join via link → «Копировать ссылку»), «**Защита паролем**» (Password
protection → «Ввести пароль»/«Копировать пароль»). Кнопки: «**Назад**», «**Создать
обсуждение**» (Create conversation). *(write — мастер открываю без сохранения / на live не
прохожу.)*
## Экран: Беседа (чат) — `ChatView` + `TopBar` + `NewMessage` *(не-пройдено-live)*
**Шапка беседы (`TopBar`):**
- «**Назад**» (Back) → список обсуждений (мобильный).
- Имя беседы/аватар → «**Настройки обсуждения**» / «**Детали**» (правый сайдбар).
- «**Начать звонок**» / «**Присоединиться к звонку**» (Start call / Join call, `CallButton`;
«Start call silently» — тихий старт). При обновлении сервера: «Nextcloud Talk was updated,
you cannot start or join a call».
- «**Ещё действия**» (More actions, `TopBarMenu`): «Настройки обсуждения», «Действия обсуждения»,
«Проверить устройства», «Открыть чат» (Open chat), полноэкранный режим (Full screen / F).
**Тело:** лента сообщений (`MessagesList`); «Нет сообщений» (No messages), «Все сообщения
истекли или были удалены», «Это обсуждение только для чтения» (read-only conversation),
«Прокрутить вниз» (Scroll to bottom), «Кто-то печатает…» (Someone is typing…).
**Композер (`NewMessage`):**
- Поле «**Отправить сообщение**» (Post message / Send message); «Отправить» (Send).
- «**Добавить эмодзи**» (Add emoji).
- «**Поделиться файлами в обсуждении**» (Share files): «Поделиться из Файлов» (Share from Files),
«Загрузить» (Add more files), «Создать и поделиться новым файлом» (Create and share a new file).
- «**Умный подборщик**» (Smart picker).
- «**Создать опрос**» (Create new poll, `PollViewer`).
- «**Записать голосовое сообщение**» (Record voice message → «Завершить и отправить»/«Отменить»).
- «**Создать тред**» (Create a thread / silently).
- Тихая отправка: «Отправить без уведомления» (Send without notification / Send message silently).
- Редактирование: «Изменить сообщение» (Edit message), «Отменить редактирование».
### Меню сообщения (`MessageButtonsBar`) *(не-пройдено-live)*
Долгое нажатие/⋯ на сообщении:
- «**Ответить**» (Reply) / «**Ответить лично**» (Reply privately).
- «**Добавить реакцию**» (Add a reaction; «React with {emoji}», меню реакций).
- «**Переслать сообщение**» (Forward message / «в "Личные заметки"» — Note to self).
- «**Копировать сообщение**» / «**Копировать ссылку на сообщение**» (Copy message / message link).
- «**Напоминание**» (Set reminder): «Сегодня позже», «Завтра», «В выходные», «На следующей
неделе», «Задать своё…»; «Очистить напоминание».
- «**Отметить как непрочитанное**» (Mark as unread).
- «**Перейти к файлу**» (Go to file) / «**Перейти к треду**» (Go to thread).
- «**Изменить сообщение**» (Edit message) / «**Изменить детали треда**».
- «**Перевести**» (Translate).
- «**Скачать файл**» (Download file).
- «**Удалить**» (Delete).
## Экран: Активный звонок — `CallView` + `TopBar`/`bottom-bar` *(не-пройдено-live, звонки запрещены)*
Нижняя панель звонка (тема `.bottom-bar-call-controls`, `.buttons-bar`):
- «**Микрофон вкл/выкл**», «**Видео вкл/выкл**» (Disable video), «**Размытие фона**»
(Disable background blur / Select virtual background).
- «**Показать экран**» (Show your screen / Enable screensharing / Screensharing options).
- «**Отправить реакцию**» (Send a reaction).
- «**Поднять руку**» (Raise/lower hand).
- «**Добавить участников**» (Add participants to this call).
- «**Запись**» (Start/Stop recording; «Starting the recording», согласие на запись).
- «**Комнаты обсуждения**» (Set up breakout rooms; «Back to main room»).
- «**Полный экран**» (Full screen / Exit full screen).
- «**Покинуть звонок**» (Leave call) / «**Завершить звонок для всех**» (End call for everyone).
- «**Скачать список присутствия**» (Download attendance list).
## Экран: Детали беседы (правый сайдбар — `RightSidebar`) *(частично не-пройдено-live)*
Табы (`RightSidebarContent`):
- «**Детали**» (Details) — инфо о беседе, «Предстоящие встречи» (Upcoming meetings,
`CalendarEventsDialog`).
- «**Участники ({count})**» (Participants) — список; действия участника (`ParticipantItem`):
«Повысить/понизить модератора» (Promote/Demote moderator), «Изменить права» (Edit permissions:
Grant all / Remove all / default), «Переместить в лобби» (Move back to lobby), «Переместить в
обсуждение» (Move to conversation), «Удалить участника» (Remove participant), «Забанить»
(Also ban…), телефонные (Dial out / Mute / Hold / Hang up / Copy phone number). Строка поиска
«Добавить участников».
- «**Чат**» (Chat / Open chat) — на узком экране.
- «**Общие ресурсы**» (Shared items, `SharedItems`) — файлы/медиа/ссылки беседы.
- «**Треды**» (Threads in {name}).
- «**Поиск сообщений**» (Search messages / Search in {name}).
- «**Комнаты обсуждения**» (Breakout rooms), «**Лобби**» (LobbyStatus), «**SIP**» (SipSettings) —
когда включены.
## Экран: Настройки обсуждения (`ConversationSettingsDialog`) *(write-разделы — не жму)*
Секции (модалка на весь экран): «**Основная информация**» (Basic Info + `ConversationAvatarEditor`),
«**Обзор настройки**» (Setup overview), «**Личное**» (Personal), «**Модерация**» (Moderation),
«**Права**» (Permissions), «**Встреча**» (Meeting), «**Комнаты обсуждения**» (Breakout Rooms),
«**Живая транскрипция**» (Live transcription), «**Боты**» (Bots), «**Matterbridge**»,
«**Опасная зона**» (Danger zone: «Архивировать обсуждение», удаление/покидание).
> Контрактно важно: разделы «Боты» и «Живая транскрипция» завязаны на серверный форк
> `f7_talk 22.0.12-f7.N` (bot API /api/v1, транскрипция ячейки A) — предмет моей сверки, не вёрстки.
## Экран: Настройки Talk (личные — `SettingsDialog`)
Открывается «Настройки приложения» (App settings) из сайдбара. Секции:
- «**Внешний вид и звуки**» (Appearance & Sounds): «Компактный список обсуждений» (Compact
conversations list), «Звуки уведомлений чата и звонков», «Проигрывать звуки при входе/выходе».
- «**Устройства**» (Devices): «Проверить устройства» (Check devices), «Размывать фон камеры по
умолчанию» (Blur camera background by default), «Пропускать предпросмотр устройства перед
звонком» (Skip device preview), «Камера вкл/выкл», «Микрофон вкл/выкл».
- «**Приватность**» (Privacy): «Отправлять уведомления о прочтении» (Send read receipts),
«Делиться статусом набора» (Share typing status).
- «**Вложения**» (Attachments): «Папка вложений» (Attachments folder → «Выбрать расположение»).
- «**Уведомления**» (Notification settings).
- «**Горячие клавиши**» (Shortcuts while in a call / in a chat) — не актуально на мобильном.
## Расхождения / бэклог (натив `feature/talk` ↔ живой forbion)
Заполняется при функциональной сверке нативного клиента (задача №1 после карты). Кандидаты из
беглого сопоставления кода `feature/talk` (Compose-UI на spreed OCS **/api/v4**) с апстримом v22:
- `расхождение:` проверить наличие в нативе: **тредов** (Threads), **напоминаний по сообщению**
(Set reminder), **опросов** (Poll), **умного подборщика** (Smart picker), **пересылки**
(Forward), **комнат обсуждения** (Breakout rooms), **живой транскрипции**.
- `расхождение:` состав меню создания «+» (в нативе — есть ли «Начать встречу сейчас» /
«Присоединиться к открытым» / «Позвонить на номер»).
- `расхождение:` секции «Настройки обсуждения» (модерация/права/лобби/SIP) — покрытие в нативе.
- Звонок (натив = `vendor/talk-android` v23 CallActivity): сверить панель управления звонком
(реакции, поднятая рука, запись, breakout, шаринг экрана) — отдельно от Compose-UI чата.
- EOL-баннер «server almost EOL» (порог мажора 26 у v23 против сервера 22) — гасим в vendor
(решение лида, mail/071); зафиксировать в `vendor/talk-android/README.F7CLOUD.md`.
Открытые (?) (доберу при функциональной сверке на устройстве / доп. live-проходе без записи):
точные подписи меню сообщения и действий участника на forbion; поведение фильтра списка;
состав правого сайдбара на узком вьюпорте (какие табы прячет тема).
+110
View File
@@ -0,0 +1,110 @@
# Diff: Задачи (Tasks) — итерация 1 (2026-07-13)
Ff_app: **24.6%** · Fd_app: **52.7%** (среднее по 8 экранам, невзвешенно) · n-a: 0
> Прогресс P1 (коммиты `23f7565`/`e405dad`/`bd56c4b`): умные коллекции выведены в панель
> (агрегат + счётчики + открытие) + 3-state видимость (Скрыта/Видима/Авто) в настройках с
> персистом в SharedPreferences. **P1 закрыт в коде.** Пункты `done` = сделано в коде, **ждёт
> девайс-сверки** (тест-аккаунт — блокер лида); подтверждённого второго прогона ещё не было.
Сверка веб (`nextcloud/tasks v0.17.1` + тема) ↔ натив (`feature/tasks`: `TasksScreen`,
`TasksComponents`, `TasksViewModel`, `TasksRepository`, `TasksSmartLists`). Метки: `[Ф|Д]`
(функционал/дизайн) · `[open|done|n-a]`. Это первый прогон — счётчики честно низкие: натив
покрывает базовый CRUD + группировку + движок умных списков (в коде, не в UI). Живую визуал-сверку
на forbion (`bmob-*`, mail/085) добавлю следующим проходом — сейчас diff из кода.
Прошу приоритизацию: предлагаемый порядок P1→P5 в конце.
---
## Экран 1. Панель навигации (списки + умные коллекции) — Ff 28.6% · Fd 80%
Ф (14, open 10):
- [Ф][done] умные коллекции в панели (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) — выведены
(`23f7565`), тап открывает агрегированный вид; ждёт девайс-сверки
- [Ф][done] счётчики-бейджи на коллекциях
- [Ф][done] открыть реальный список
- [Ф][open] «Добавить список» (MKCALENDAR) — сейчас мёртвая кнопка
- [Ф][open] выбор цвета при создании списка
- [Ф][open] drag-сортировка списков
- [Ф][open] drag задачи на коллекцию (retag priority/complete/today/week)
- [Ф][open] открыть Корзину
- [Ф][done] открыть Настройки
- [Ф][open] «⋯» списка: поделиться · [Ф][open] изменить · [Ф][open] копировать ссылку ·
[Ф][open] экспорт · [Ф][open] удалить/unshare
Д (5, open 1): [Д][done] композиция (коллекции показаны) · [Д][done] иконки коллекций ·
[Д][done] вид счётчика · [Д][done] активный пункт (фон) · [Д][open] порядок (в вебе коллекции
выше списков и кнопки «Добавить»; в нативе кнопка «Добавить» сверху) · строка списка (40dp/иконка)
## Экран 2. Список задач — Ff 38.9% · Fd 75%
Ф (18, open 11):
- [Ф][done] создать задачу (быстрый ввод) · [Ф][open] создать несколько из вставки
- [Ф][open] фильтр по меткам · [Ф][open] сброс фильтра
- [Ф][open] выбор порядка сортировки (10 вариантов) · [Ф][done] направление ↑/↓
- [Ф][open] «Загрузить завершённые» (по списку/всем)
- [Ф][done] чекбокс выполнения · [Ф][done] открыть деталь · [Ф][done] поиск по тексту
- [Ф][done] добавить подзадачу · [Ф][done] удалить задачу
- [Ф][open] «⋯»: переключить важность (star) · [Ф][open] показать/скрыть подзадачи ·
[Ф][open] показать/скрыть завершённые подзадачи
- [Ф][open] индикатор pin · [Ф][open] индикатор «есть заметка»
Д (8, open 2): [Д][done] группировка по сроку · [Д][done] зона завершённых · [Д][done] строка
задачи · [Д][done] бейдж даты · [Д][done] пусто-состояние · [Д][done] чекбокс ·
[Д][open] звезда приоритета в строке · [Д][open] иконки/вид меню сортировки-фильтра
## Экран 3. Деталь задачи — Ff 30% · Fd 66.7%
Ф (20, open 14):
- [Ф][done] правка заголовка · [Ф][done] срок · [Ф][open] дата начала · [Ф][open] «весь день»
- [Ф][open] статус (Требует действия/В процессе/Отменено/Очистить) · [Ф][open] классификация
- [Ф][done] приоритет · [Ф][open] %-выполнения (слайдер)
- [Ф][open] метки · [Ф][open] локация · [Ф][open] URL · [Ф][open] смена списка задачи
- [Ф][open] напоминания (до старта/до срока/дата-время) · [Ф][done] заметка/описание
- [Ф][open] закрепить (pin) · [Ф][open] показать в Календаре · [Ф][open] показать в Deck
- [Ф][open] экспорт · [Ф][done] удалить · [Ф][done] переключить выполнение
Д (6, open 2): [Д][done] лейаут шита · [Д][open] вкладки Подробно/Заметки · [Д][done] pill-поля ·
[Д][done] нативные пикеры · [Д][done] типографика · [Д][open] слайдеры приоритет/%
## Экран 4. Параметры задач (Настройки) — Ff 50% · Fd 100%
Ф (2, open 1): [Ф][open] список по умолчанию (в UI есть, ещё не персистится) ·
[Ф][done] видимость коллекций — **3-state (Скрыта/Видима/Авто) + персист SharedPreferences +
влияет на панель** (`bd56c4b`); ждёт девайс-сверки
Д (2, open 0): [Д][done] композиция шита · [Д][done] контрол 3-state (Скрыта/Видима/Авто)
## Экран 5. Правка/создание списка — Ff 0% · Fd 0% (экрана нет в нативе)
Ф (3, open 3): [Ф][open] имя списка · [Ф][open] цвет · [Ф][open] сохранить (PROPPATCH)
Д (1, open 1): [Д][open] модалка правки
## Экран 6. Шаринг списка — Ff 0% · Fd 0% (нет в нативе)
Ф (4, open 4): [Ф][open] поиск пользователей/групп · [Ф][open] выдать доступ ·
[Ф][open] список получателей · [Ф][open] отозвать
Д (1, open 1): [Д][open] модалка шаринга
## Экран 7. Корзина — Ff 0% · Fd 0% (нет в нативе)
Ф (4, open 4): [Ф][open] список удалённых · [Ф][open] восстановить · [Ф][open] очистить ·
[Ф][open] сортировка по дате
Д (1, open 1): [Д][open] модалка/таблица корзины
## Экран 8. Диалоги создания — Ff 50% · Fd 100%
Ф (2, open 1): [Ф][done] создать задачу · [Ф][open] создать несколько из вставки
Д (1, open 0): [Д][done] диалог создания
---
## Итог и предлагаемый приоритет
Ff_app = (28.6+38.9+30+50+0+0+0+50)/8 = **24.6%**;
Fd_app = (80+75+66.7+100+0+0+0+100)/8 = **52.7%**. Обе далеко от 98% — Задачи требуют нескольких
итераций. Крупные функциональные дыры (по влиянию на Ff):
- **P1 — Умные коллекции в UI** (`TasksListNavView` + счётчики + 3-state видимость с персистом).
**СДЕЛАНО в коде** (`23f7565`/`e405dad`/`bd56c4b`: коллекции+счётчики+открытие + 3-state
видимость + персист SharedPreferences). Ждёт девайс-сверки/подтверждающего прогона.
- **P2 — Управление списками**: рабочее «Добавить список» + цвет, «⋯» списка (переименовать/цвет/
удалить/экспорт/копировать ссылку), шаринг. Часть — core DAV (PROPPATCH/MKCALENDAR/share) —
**согласую с тобой ДО** (образец у calendar). Мой модуль + core-точки.
- **P3 — Богатая деталь**: старт-дата/весь день, статус, %-слайдер, метки, локация/URL, смена
списка, напоминания, pin. Метки/напоминания — крупные подсистемы; уточню объём.
- **P4 — Сортировка (10 порядков) + фильтр по меткам** вместо тумблера; «Загрузить завершённые».
- **P5 — Корзина** (DAV trashbin) — навигация/core, вероятно твой ход.
Вопросы к тебе: (1) порядок P1–P5 ок? (2) метки (tags) и напоминания (VALARM) — беру целиком в
`feature/tasks` или это кросс-модульное (метки шарятся с Календарём)? (3) Корзину относить в мой
модуль или это core/навигация? (4) экраны 5/6/7 считать полноценными (тянут среднее в 0) или
частью экрана навигации — уточни методику усреднения для «отсутствующих» экранов.
+190
View File
@@ -0,0 +1,190 @@
# Карта: Задачи (Tasks)
Источник: апстрим `nextcloud/tasks v0.17.1` (`src/**/*.vue`, точная версия forbion) + тема
`_mobile-tasks.css` (+ `app-tasks/forms/*`). Живой мобильный web (Browser 1) — для порядка/вида,
помечено `визуал(?)` где не подтверждено на устройстве. Составил `b-mob-tasks` (mail/075/078/087).
Легенда: **[веб]** — как в мобильном вебе forbion; `расхождение:` — чего нет/иначе в нативе
(вход в `tasks.diff.md`).
---
## Вход
Нижняя панель / шторка приложений → «Задачи». Открывается **Панель навигации** (в вебе — левая
`app-navigation`; в нативе — экран списков `TasksListNavView`).
---
## Экран: Панель навигации (списки + умные коллекции)
Источник: `views/AppNavigation.vue`.
**Тело (сверху вниз):**
1. **Умные коллекции** (виртуальные списки, каждый со счётчиком-бейджем `NcCounterBubble`):
- «Важное» (starred, иконка Star) → `/collections/starred`
- «Сегодня» (today, CalendarToday) → `/collections/today`
- «На неделе» (week, CalendarWeek) → `/collections/week`
- «Все» (all, CircleOutline) → `/collections/all`
- «Текущие» (current, TrendingUp) → `/collections/current`
- «Завершённые» (completed, Check) → `/collections/completed`
- Видимость каждой управляется настройкой (Скрыта/Видима/Автоматически — см. модалку Настроек);
«Автоматически» = показывать только если счётчик > 0.
2. **Реальные списки** (календари VTODO), перетаскиваемые для ручной сортировки (long-press drag).
У каждого — «⋯» меню (см. ниже).
3. **«Add List…» / «Добавить список»** (иконка Plus) → инлайн-форма: поле «New list» +
**выбор цвета** (`Colorpicker`); Enter/стрелка — создать (MKCALENDAR), Esc — отмена.
4. **«Корзина» / «Trash bin»** (иконка Delete, если сервер поддерживает) → модалка Корзины.
5. **Настройки** (шестерёнка в футере) → модалка «Параметры задач».
**Drag&drop:** задачу можно перетащить на коллекцию starred/completed/today/week — это
проставляет приоритет=1 / 100% / срок сегодня / срок +6 дней соответственно.
`расхождение:` в нативе (`TasksListNavView`) показаны ТОЛЬКО реальные списки; умных коллекций,
Корзины, drag-сортировки и drag-на-коллекцию нет; «Добавить список» — мёртвая кнопка (без обработчика).
### Всплывающее меню: «⋯» списка (в панели навигации)
Источник: `components/AppNavigation/ListItemCalendar.vue`. Пункты по порядку:
1. «Поделиться» (Share, если список расшариваем) → панель шаринга (пользователи/группы).
2. «Изменить» (Edit) → форма правки списка: имя + цвет.
3. «Копировать личную ссылку» (Copy private link → «Скопировано»/«Не удалось»).
4. «Экспорт» (Export) → скачивание списка `.ics`.
5. «Удалить» / «Отменить общий доступ» (Delete/Unshare, с обратным отсчётом → «Отмена»).
6. (внутри правки) «Удалить все завершённые задачи» → модалка (см. `DeleteCompletedModal`).
`расхождение:` в нативе у списков НЕТ «⋯»-меню вовсе (нельзя переименовать/цвет/шарить/экспорт/
удалить/копировать ссылку).
---
## Экран: Список задач (коллекция или реальный список)
Источник: `components/HeaderBar.vue`, `views/AppContent/General.vue`, `components/TaskBody.vue`.
**Шапка (`HeaderBar`):**
- **Поле быстрого ввода** «Add a task…» (иконка Plus): Enter/стрелка — создать задачу; вставка
многострочного текста → диалог «Создать несколько задач». Скрыто для коллекции «Завершённые»
и для read-only списка.
- **«Фильтр»** (`FilterDropdown`, иконка воронки; активна — подсвечена) → меню (см. ниже).
- **«Сортировка»** (`SortorderDropdown`, иконка текущего порядка + стрелка направления) → меню
(см. ниже).
**Тело:**
- Список задач (по выбранному порядку). Незавершённые сверху; завершённые — отдельной зоной.
- Коллекция «Завершённые» / завершённые в списке: кнопка **«Загрузить завершённые задачи»**
(`LoadCompletedButton`; варианты «…этого списка» / «…всех списков»).
- Пустое состояние: плейсхолдер без задач. `визуал(?)`.
### Строка задачи + всплывающее меню «⋯»
Источник: `components/TaskBody.vue`. Элементы строки:
- **Чекбокс** выполнения (клик — переключить 0/100%); показывает % выполнения.
- **Звезда** (если приоритет > 0).
- **Заголовок** (клик — открыть деталь в сайдбаре).
- **Дата** старта/срока (бейдж: Вчера/Сегодня/Завтра/дата; для завершённых — «Завершено …»).
- Индикаторы: **закреплена** (Pin), **есть заметка** (клик → сайдбар, вкладка Заметки),
**есть скрытые подзадачи** (SortVariant).
- **Меню «⋯»** — пункты по порядку:
1. «Добавить подзадачу» (Add subtask) → инлайн-поле ввода названия.
2. «Показать/скрыть подзадачи» (если есть подзадачи).
3. «Показать/скрыть завершённые подзадачи» (если есть завершённые подзадачи).
4. «Удалить задачу» (Delete task, с обратным отсчётом отмены).
5. «Переключить важность» (Toggle starred).
`расхождение:` нативное «⋯» (`TasksComponents.TasksRow`) имеет только «Добавить вложенную
задачу» и «Удалить задачу»; нет toggle-звезды, show/hide подзадач, индикаторов pin/заметка/
скрытые-подзадачи; звезды приоритета в строке нет.
### Выпадающее меню: Сортировка (`SortorderDropdown`)
10 порядков (клик повторно — сменить направление ↑/↓):
Актуальность (Relevance) · Дата начала · Дата выполнения (due) · Дата создания · Изменено ·
Дата завершения · Приоритет · По алфавиту · Метки (Tags) · Вручную (Manually).
`расхождение:` в нативе только тумблер ↑/↓ по одному критерию (дата→имя), выбора из 10 порядков нет.
### Выпадающее меню: Фильтр (`FilterDropdown`)
- «Фильтровать по меткам» (мультиселект тегов).
- «Сбросить фильтр».
`расхождение:` в нативе кнопка «фильтр» открывает шит Настроек (не фильтр по тегам); тегов нет,
фильтрации по тегам нет. Поиск по тексту в нативе есть (в VM), но отдельной строки поиска в вебе
на этом экране нет — сверить `визуал(?)`.
---
## Экран: Деталь задачи (правый сайдбар)
Источник: `views/AppSidebar.vue` (+ `components/AppSidebar/*`).
**Шапка сайдбара:** заголовок (редактируемый), подзаголовок (даты). Меню действий:
- «Закрепить»/«Открепить» (Pin/Unpin).
- «Показать в Календаре» (Show in Calendar).
- «Показать в Deck» (Show in Deck).
- «Изменить заголовок» (Edit summary).
- «Экспорт» (Export).
- «Удалить» (Delete).
**Вкладка «Подробно» (Details):**
- **Классификация** (Select a classification): Public/Private/Confidential.
- **Статус** (Select a status): Требует действия · Выполнено · В процессе · Отменено · Очистить.
- **Дата начала** (Set start date) + **Дата выполнения** (Set due date), флажок **«Весь день»**.
- **Приоритет** (слайдер): нет / низкий / средний / высокий (1–9).
- **% выполнения** (слайдер/поле).
- **Метки** (Select tags).
- **Расположение** (Set a location), **URL** (Set a URL).
- **Список** (переключение календаря задачи).
- **Напоминания** (`AlarmList`): меню «Добавить напоминание» → «До начала задачи» / «До срока» /
«Выбрать дату и время»; список напоминаний, «Нет напоминаний» — пусто.
**Вкладка «Заметки» (Notes):** многострочная заметка (Markdown).
`расхождение:` натив (`TaskDetailSheet`) — только заголовок, срок, приоритет, описание, toggle
выполнения, удалить. Нет: старт-даты/всего дня, статуса (кроме bool), классификации, %-слайдера,
меток, локации, URL, смены списка, напоминаний, Pin, «Показать в Календаре/Deck», экспорта,
разделения на вкладки Подробно/Заметки.
---
## Модалка: Параметры задач (Настройки)
Источник: `components/AppNavigation/AppNavigationSettings.vue`.
- «Список по умолчанию» (Default list) — селект.
- «Видимость умных коллекций» (Visibility of Smart Collections) — по каждой коллекции 3 варианта:
**Скрыта / Видима / Автоматически**.
`расхождение:` в нативе (`TasksSettingsSheet`) видимость — только 2 варианта (Показывать/Скрывать),
не персистится и ни на что не влияет; «список по умолчанию» не персистится.
## Модалка: Правка/создание списка
Источник: `ListItemCalendar.vue` (форма) + тема `_tasks-list-edit-mobile.css`.
- Поле «Имя списка» + **Colorpicker**. Сохранить/Отмена. `расхождение:` нет в нативе.
## Модалка: Шаринг списка
Источник: тема `_tasks-list-shares-mobile.css` + `ListItemCalendar` share.
- Поиск пользователей/групп, выдача доступа, список получателей. `расхождение:` нет в нативе.
## Модалка: Удалить завершённые
Источник: `components/DeleteCompletedModal.vue`.
- «Удалить все завершённые задачи» → подтверждение, отчёт «Удалено из списка …».
- В нативе аналог есть: массовое удаление завершённых в зоне «Завершённые» (`deleteCompletedTasks`).
## Модалка: Фильтр по тегам
Источник: тема `_tasks-filter-popover-mobile.css` + `FilterDropdown`. `расхождение:` нет в нативе.
## Экран: Корзина (Trash bin)
Источник: `components/AppNavigation/Trashbin.vue` + тема `_tasks-trash-modal-mobile.css`.
- Таблица удалённых (имя, дата удаления, сортировка), «Восстановить», «Очистить корзину»,
пусто-состояние «Нет удалённых…». `расхождение:` нет в нативе.
## Диалоги создания
Источник: `TaskCreateDialog.vue` (Создать задачу: поле «Краткое описание», Отмена/Создать),
`CreateMultipleTasksDialog.vue` (из вставленного текста: «Создать N задач», Отмена/Создать).
`расхождение:` быстрый ввод в нативе есть; массового создания из вставки — нет.
---
## Сводка всплывающих меню (mail/087) — веб ↔ натив
| Меню | Веб (пунктов) | Натив |
|---|---|---|
| «⋯» списка (навигация) | 6 (share/edit/copy-link/export/delete/del-completed) | нет меню |
| «⋯» задачи | 5 (subtask/show-hide/show-hide-closed/delete/star) | 2 (subtask/delete) |
| Сортировка | 10 порядков + направление | тумблер ↑/↓ |
| Фильтр | по тегам + сброс | нет (кнопка ведёт в Настройки) |
| Создание «+» | задача / из вставки | задача |
| Действия сайдбара | 6 (pin/calendar/deck/edit/export/delete) | 2 (toggle/delete) |
| Напоминания | 3 (до старта/до срока/дата-время) | нет |
| Настройки: видимость коллекций | 3 (скрыта/видима/авто) ×6 | 2 (показ/скрыть), неактивно |
@@ -55,10 +55,12 @@ fun DeckCardSheet(
stacks: List<DeckStack>,
canEdit: Boolean,
busy: Boolean,
assignedToMe: Boolean,
onDismiss: () -> Unit,
onToggleDone: () -> Unit,
onSave: (title: String, description: String, duedate: String?) -> Unit,
onToggleLabel: (DeckLabel) -> Unit,
onToggleAssignSelf: () -> Unit,
onMove: (targetStackId: Int) -> Unit,
onArchive: () -> Unit,
onDelete: () -> Unit,
@@ -146,6 +148,24 @@ fun DeckCardSheet(
}
}
// Исполнители
Text("Исполнители", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
if (card.assignees.isNotEmpty()) {
Text(
card.assignees.joinToString(", "),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
}
if (canEdit) {
F7SecondaryButton(
text = if (assignedToMe) "Снять назначение с меня" else "Назначить мне",
onClick = onToggleAssignSelf,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
}
// Переместить в колонку
if (canEdit && stacks.size > 1) {
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
@@ -99,6 +99,7 @@ class DeckRepository {
stackId = stackId,
labels = parseLabels(card.optJSONArray("labels")),
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
assigneeUids = parseAssigneeUids(card.optJSONArray("assignedUsers")),
)
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
@@ -126,6 +127,21 @@ class DeckRepository {
return out
}
/** uid'ы назначенных пользователей (type 0 — user) — для «назначен ли текущий пользователь». */
private fun parseAssigneeUids(array: JSONArray?): List<String> {
if (array == null) return emptyList()
val out = mutableListOf<String>()
for (i in 0 until array.length()) {
val a = array.optJSONObject(i)
if (a != null && a.optInt("type", 0) == 0) {
val uid = a.optJSONObject("participant")?.optString("uid")?.takeIf { it.isNotBlank() }
?: a.optString("participant").takeIf { it.isNotBlank() }
if (!uid.isNullOrBlank()) out += uid
}
}
return out
}
// --- Запись ---
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
@@ -179,6 +195,18 @@ class DeckRepository {
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
}
/** Назначить пользователя (type 0) на карточку. */
fun assignUser(session: AuthSession, cardId: Int, uid: String) {
val body = JSONObject().put("userId", uid).put("type", 0)
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/assign", body)
}
/** Снять назначение пользователя (type 0) с карточки. */
fun unassignUser(session: AuthSession, cardId: Int, uid: String) {
val body = JSONObject().put("userId", uid).put("type", 0)
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/unassign", body)
}
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
@@ -258,6 +286,7 @@ data class DeckCard(
val stackId: Int = 0,
val labels: List<DeckLabel> = emptyList(),
val assignees: List<String> = emptyList(),
val assigneeUids: List<String> = emptyList(),
)
data class DeckBoardDetail(
@@ -135,10 +135,12 @@ fun DeckScreen(
stacks = state.boardDetail?.stacks.orEmpty(),
canEdit = state.boardDetail?.canEdit ?: false,
busy = state.busy,
assignedToMe = card.assigneeUids.contains(session.username),
onDismiss = vm::closeCard,
onToggleDone = { vm.toggleDone(session, card) },
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
onToggleLabel = { vm.toggleLabel(session, card, it) },
onToggleAssignSelf = { vm.toggleAssignSelf(session, card) },
onMove = { vm.moveCard(session, card, it) },
onArchive = { vm.archiveCard(session, card) },
onDelete = { vm.deleteCard(session, card) },
@@ -111,6 +111,14 @@ class DeckViewModel(
}
}
fun toggleAssignSelf(session: AuthSession, card: DeckCard) = mutate(session) {
if (card.assigneeUids.contains(session.username)) {
repository.unassignUser(session, card.id, session.username)
} else {
repository.assignUser(session, card.id, session.username)
}
}
fun createStack(session: AuthSession, title: String) {
val boardId = _state.value.selectedBoardId ?: return
mutate(session) { repository.createStack(session, boardId, title.trim()) }
+3
View File
@@ -43,4 +43,7 @@ dependencies {
implementation libs.documentfile
implementation libs.coil.compose
implementation libs.coil.svg
implementation libs.coil.gif
implementation libs.media3.exoplayer
implementation libs.media3.ui
}
@@ -6,6 +6,15 @@
android:name=".ImageViewerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<activity
android:name=".MediaPlayerActivity"
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.NoActionBar" />
<activity
android:name=".PdfViewerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.NoActionBar" />
<provider
android:name=".F7FileProvider"
@@ -2,5 +2,8 @@ package ru.forbion.f7cloud.feature.files
sealed class FileOpenAction {
data class Image(val path: String, val title: String) : FileOpenAction()
data class Pdf(val path: String, val title: String) : FileOpenAction()
/** Видео/аудио — нативный плеер Media3 (isVideo=false → аудио-режим). */
data class Media(val path: String, val title: String, val isVideo: Boolean) : FileOpenAction()
data class External(val path: String, val mimeType: String, val title: String) : FileOpenAction()
}
@@ -144,6 +144,14 @@ fun FilesScreen(
context.startActivity(ImageViewerActivity.intent(context, action.path, action.title))
vm.clearOpenAction()
}
is FileOpenAction.Pdf -> {
context.startActivity(PdfViewerActivity.intent(context, action.path, action.title))
vm.clearOpenAction()
}
is FileOpenAction.Media -> {
context.startActivity(MediaPlayerActivity.intent(context, action.path, action.title, action.isVideo))
vm.clearOpenAction()
}
is FileOpenAction.External -> {
LocalFileOpener.openExternal(context, File(action.path), action.mimeType)
vm.clearOpenAction()
@@ -491,6 +491,9 @@ class FilesViewModel(
WebOpenableFiles.isWebOpenable(item.name) -> openWebFile(session, item)
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
OpenableFiles.isPdf(item.name) -> openMediaFile(session, item) { p -> FileOpenAction.Pdf(p, item.name) }
OpenableFiles.isVideo(item.name) -> openMediaFile(session, item) { p -> FileOpenAction.Media(p, item.name, isVideo = true) }
OpenableFiles.isAudio(item.name) -> openMediaFile(session, item) { p -> FileOpenAction.Media(p, item.name, isVideo = false) }
else -> _state.value = _state.value.copy(
error = "Этот тип файла пока не поддерживается",
)
@@ -573,6 +576,24 @@ class FilesViewModel(
}
}
/** Медиа (PDF/видео/аудио) — скачиваем в кэш (WebDAV Basic-auth) и открываем нативным вьюером. */
private fun openMediaFile(session: AuthSession, item: FileItem, toAction: (String) -> FileOpenAction) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(openingFile = null, openAction = toAction(file.absolutePath))
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось открыть файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun openImageFile(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
@@ -0,0 +1,121 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import ru.forbion.f7cloud.core.designsystem.F7Theme
import java.io.File
/** Нативный проигрыватель видео/аудио (Media3/ExoPlayer). Файл уже скачан в кэш. */
class MediaPlayerActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val path = intent.getStringExtra(EXTRA_PATH).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, true)
if (path.isBlank()) {
finish()
return
}
setContent {
F7Theme {
MediaPlayerScreen(path = path, title = title, isVideo = isVideo, onClose = { finish() })
}
}
}
companion object {
private const val EXTRA_PATH = "path"
private const val EXTRA_TITLE = "title"
private const val EXTRA_IS_VIDEO = "is_video"
fun intent(context: Context, path: String, title: String, isVideo: Boolean): Intent =
Intent(context, MediaPlayerActivity::class.java).apply {
putExtra(EXTRA_PATH, path)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_IS_VIDEO, isVideo)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MediaPlayerScreen(
path: String,
title: String,
isVideo: Boolean,
onClose: () -> Unit,
) {
val context = LocalContext.current
val player = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(Uri.fromFile(File(path))))
prepare()
playWhenReady = true
}
}
DisposableEffect(Unit) {
onDispose { player.release() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = true
setShowNextButton(false)
setShowPreviousButton(false)
// Аудио: PlayerView без видео-поверхности показывает только контролы.
}
},
modifier = Modifier.fillMaxSize(),
)
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Text("", color = Color.White, modifier = Modifier.padding(8.dp))
}
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
@@ -4,23 +4,40 @@ import java.util.Locale
enum class OpenableKind {
IMAGE,
VIDEO,
AUDIO,
PDF,
}
object OpenableFiles {
private val IMAGE_EXT = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif")
private val IMAGE_EXT = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif", "svg")
private val VIDEO_EXT = setOf("mp4", "webm", "mkv", "mov", "3gp", "m4v", "avi", "ts")
private val AUDIO_EXT = setOf("mp3", "aac", "ogg", "oga", "opus", "wav", "flac", "m4a", "weba")
private val PDF_EXT = setOf("pdf")
fun kind(name: String): OpenableKind? {
val ext = name.lowercase(Locale.ROOT).substringAfterLast('.', missingDelimiterValue = "")
return if (ext in IMAGE_EXT) OpenableKind.IMAGE else null
return when (ext) {
in IMAGE_EXT -> OpenableKind.IMAGE
in VIDEO_EXT -> OpenableKind.VIDEO
in AUDIO_EXT -> OpenableKind.AUDIO
in PDF_EXT -> OpenableKind.PDF
else -> null
}
}
fun isOpenable(name: String): Boolean = kind(name) != null || ArchiveFiles.isArchive(name)
fun isImage(name: String): Boolean = kind(name) == OpenableKind.IMAGE
fun isVideo(name: String): Boolean = kind(name) == OpenableKind.VIDEO
fun isAudio(name: String): Boolean = kind(name) == OpenableKind.AUDIO
fun isPdf(name: String): Boolean = kind(name) == OpenableKind.PDF
fun hint(name: String): String? = when {
isImage(name) -> "Просмотр"
ArchiveFiles.isArchive(name) -> "Открыть с помощью…"
else -> null
fun hint(name: String): String? = when (kind(name)) {
OpenableKind.IMAGE -> "Просмотр"
OpenableKind.VIDEO -> "Видео"
OpenableKind.AUDIO -> "Аудио"
OpenableKind.PDF -> "PDF"
null -> if (ArchiveFiles.isArchive(name)) "Открыть с помощью…" else null
}
}
@@ -0,0 +1,149 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import android.graphics.pdf.PdfRenderer
import android.os.Bundle
import android.os.ParcelFileDescriptor
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.designsystem.F7Theme
import java.io.File
/** Нативный просмотр PDF через android.graphics.pdf.PdfRenderer (без внешних зависимостей). */
class PdfViewerActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val path = intent.getStringExtra(EXTRA_PATH).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
if (path.isBlank() || !File(path).exists()) {
finish()
return
}
setContent {
F7Theme {
PdfViewerScreen(path = path, title = title, onClose = { finish() })
}
}
}
companion object {
private const val EXTRA_PATH = "path"
private const val EXTRA_TITLE = "title"
fun intent(context: Context, path: String, title: String): Intent =
Intent(context, PdfViewerActivity::class.java).apply {
putExtra(EXTRA_PATH, path)
putExtra(EXTRA_TITLE, title)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PdfViewerScreen(
path: String,
title: String,
onClose: () -> Unit,
) {
val pages by produceState(initialValue = emptyList<Bitmap>(), path) {
value = withContext(Dispatchers.IO) { renderPdf(File(path)) }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF303030)),
) {
if (pages.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp, start = 8.dp, end = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(pages) { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().background(Color.White),
contentScale = ContentScale.FillWidth,
)
}
}
}
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Text("", color = Color.White, modifier = Modifier.padding(8.dp))
}
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
/** Рендер всех страниц PDF в bitmap'ы (ширина ~1080px, высота по соотношению сторон). */
private fun renderPdf(file: File): List<Bitmap> {
val out = mutableListOf<Bitmap>()
runCatching {
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
PdfRenderer(pfd).use { renderer ->
val targetWidth = 1080
for (i in 0 until renderer.pageCount) {
renderer.openPage(i).use { page ->
val scale = targetWidth.toFloat() / page.width
val height = (page.height * scale).toInt().coerceAtLeast(1)
val bmp = Bitmap.createBitmap(targetWidth, height, Bitmap.Config.ARGB_8888)
bmp.eraseColor(AndroidColor.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
out += bmp
}
}
}
}
}
return out
}
+42
View File
@@ -0,0 +1,42 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.notes'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:designsystem')
implementation libs.coroutines.android
def composeBom = platform(libs.compose.bom)
implementation composeBom
implementation libs.compose.ui
implementation libs.compose.material3
implementation libs.compose.foundation
implementation libs.lifecycle.viewmodel.compose
implementation libs.coil.compose
implementation libs.coil.svg
testImplementation libs.junit
testImplementation libs.okhttp
testImplementation libs.okhttp.mockwebserver
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,45 @@
package ru.forbion.f7cloud.feature.notes
import org.json.JSONArray
import org.json.JSONObject
/**
* Заметка (Notes API v1). Поля контракта: id, etag, readonly, content, title, category,
* favorite, modified. Источник модели `nextcloud/notes` v5.0.0 (карта app-map/notes.md).
*/
data class Note(
val id: Long,
val etag: String = "",
val readonly: Boolean = false,
val title: String = "",
val category: String = "",
val content: String = "",
val favorite: Boolean = false,
val modified: Long = 0L,
) {
companion object {
/** Разбор одной заметки из JSON ответа API v1. */
fun fromJson(obj: JSONObject): Note = Note(
id = obj.optLong("id", 0L),
etag = obj.optString("etag"),
readonly = obj.optBoolean("readonly", false),
title = obj.optString("title"),
category = obj.optString("category"),
content = obj.optString("content"),
favorite = obj.optBoolean("favorite", false),
modified = obj.optLong("modified", 0L),
)
/** Разбор списка заметок (GET /notes, в т.ч. с exclude=content). */
fun listFromJson(array: JSONArray): List<Note> {
val out = ArrayList<Note>(array.length())
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i)
if (obj != null && obj.optLong("id", 0L) > 0L) {
out += fromJson(obj)
}
}
return out
}
}
}
@@ -0,0 +1,14 @@
package ru.forbion.f7cloud.feature.notes
/**
* Конфликт версий заметки: сервер вернул 412 Precondition Failed на PUT с `If-Match`
* (заметка изменена в другой сессии). Разрешается ЯВНЫМ выбором пользователя (версия сервера
* или текущая), НЕ молчаливой перезаписью согласовано с лидом (mail/071 п.2, диалог
* ConflictSolution в карте app-map/notes.md).
*
* @param serverNote версия с сервера (если её удалось перечитать) для показа в диалоге.
*/
class NoteConflictException(
val noteId: Long,
val serverNote: Note? = null,
) : RuntimeException("Note $noteId changed on server (412)")
@@ -0,0 +1,142 @@
package ru.forbion.f7cloud.feature.notes
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* Клиент публичного Notes API v1 (`/index.php/apps/notes/api/v1`). Стабильный контракт для
* сторонних клиентов (его же использует официальный Nextcloud Notes Android): ETag на
* коллекцию и заметку, `If-None-Match` для дешёвого рефреша, `exclude=content` для лёгкого
* списка, `If-Match` для конфликтов при записи (mail/071, карта app-map/notes.md).
*
* `client` инъектируется (в проде [ru.forbion.f7cloud.core.network.NetworkFactory]
* .newAuthedClient), что делает клиент юнит-тестируемым через MockWebServer пишущие
* сценарии проверяются юнитами, НЕ на боевом forbion (mail/073).
*/
class NotesApiClient(
private val client: OkHttpClient,
serverUrl: String,
) {
private val apiBase = serverUrl.trimEnd('/') + "/index.php/apps/notes/api/v1"
/** Результат листинга: заметки + ETag коллекции (для последующего If-None-Match). */
data class ListResult(val notes: List<Note>, val etag: String, val notModified: Boolean = false)
/**
* GET /notes список. [excludeContent] `exclude=content` (лёгкий список).
* [pruneBefore] инкрементальный синк (только изменённые после метки). [ifNoneMatch]
* ETag коллекции; при 304 возвращается [ListResult.notModified] = true с пустым списком.
*/
fun list(
excludeContent: Boolean = true,
pruneBefore: Long? = null,
ifNoneMatch: String? = null,
): ListResult {
val params = buildList {
if (excludeContent) add("exclude=content")
if (pruneBefore != null) add("pruneBefore=$pruneBefore")
}
val url = apiBase + "/notes" + if (params.isEmpty()) "" else "?" + params.joinToString("&")
val req = Request.Builder()
.url(url)
.header("Accept", "application/json")
.apply { if (!ifNoneMatch.isNullOrBlank()) header("If-None-Match", ifNoneMatch) }
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == HTTP_NOT_MODIFIED) {
return ListResult(emptyList(), ifNoneMatch.orEmpty(), notModified = true)
}
if (!resp.isSuccessful) error("Notes list HTTP ${resp.code}")
val body = resp.body?.string().orEmpty()
val notes = Note.listFromJson(JSONArray(if (body.isBlank()) "[]" else body))
return ListResult(notes, resp.header("ETag").orEmpty())
}
}
/** GET /notes/{id} — одна заметка (с содержимым). */
fun get(id: Long): Note {
val req = Request.Builder()
.url("$apiBase/notes/$id")
.header("Accept", "application/json")
.build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes get HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
/** POST /notes — создать заметку. */
fun create(content: String, category: String = ""): Note {
val payload = JSONObject().put("content", content)
if (category.isNotEmpty()) payload.put("category", category)
val req = Request.Builder()
.url("$apiBase/notes")
.header("Accept", "application/json")
.post(payload.toString().toRequestBody(JSON))
.build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes create HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
/**
* PUT /notes/{id} с телом `content` и заголовком `If-Match: "<etag>"`. На 412 конфликт
* версий: перечитываем серверную версию и бросаем [NoteConflictException] (НЕ молчаливо
* перезаписываем mail/071 п.2).
*/
fun saveContent(note: Note): Note {
val payload = JSONObject().put("content", note.content)
val builder = Request.Builder()
.url("$apiBase/notes/${note.id}")
.header("Accept", "application/json")
.put(payload.toString().toRequestBody(JSON))
if (note.etag.isNotBlank()) builder.header("If-Match", "\"${note.etag}\"")
client.newCall(builder.build()).execute().use { resp ->
if (resp.code == HTTP_PRECONDITION_FAILED) {
val server = runCatching { get(note.id) }.getOrNull()
throw NoteConflictException(note.id, server)
}
if (!resp.isSuccessful) error("Notes save HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
/** PUT /notes/{id} — обновить метаданные (favorite/category/title) без If-Match. */
private fun patchMeta(id: Long, field: String, value: Any): Note {
val payload = JSONObject().put(field, value)
val req = Request.Builder()
.url("$apiBase/notes/$id")
.header("Accept", "application/json")
.put(payload.toString().toRequestBody(JSON))
.build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes patch HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
fun setFavorite(id: Long, favorite: Boolean): Note = patchMeta(id, "favorite", favorite)
fun setCategory(id: Long, category: String): Note = patchMeta(id, "category", category)
fun rename(id: Long, title: String): Note = patchMeta(id, "title", title)
/** DELETE /notes/{id}. */
fun delete(id: Long) {
val req = Request.Builder().url("$apiBase/notes/$id").delete().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes delete HTTP ${resp.code}")
}
}
private companion object {
val JSON = "application/json; charset=utf-8".toMediaType()
const val HTTP_NOT_MODIFIED = 304
const val HTTP_PRECONDITION_FAILED = 412
}
}
@@ -0,0 +1,49 @@
package ru.forbion.f7cloud.feature.notes
import android.content.Context
import java.io.File
import java.security.MessageDigest
/**
* Дисковый кэш списка заметок, ключуемый ETag коллекции (образец CalendarEventsCache).
* Формат файла: первая строка ETag на момент загрузки, дальше сырой JSON массива заметок
* (переиспользуем боевой парсер [Note.listFromJson], без хрупкого промежуточного маппинга).
*
* Даёт: (1) offline-отдачу списка при отсутствии сети; (2) быстрый рефреш через
* `If-None-Match` при 304 сеть не качает тело, читаем диск.
*/
class NotesCache(private val dir: File) {
constructor(context: Context) : this(File(context.cacheDir, "notes_list"))
data class Entry(val etag: String, val json: String)
/** Кэш списка для аккаунта, если есть; иначе null. */
fun get(accountKey: String): Entry? {
val f = entryFile(accountKey)
if (!f.isFile) return null
return runCatching {
val text = f.readText()
val nl = text.indexOf('\n')
if (nl <= 0) null else Entry(text.substring(0, nl), text.substring(nl + 1))
}.getOrNull()
}
fun put(accountKey: String, etag: String, json: String) {
if (etag.isBlank()) return
runCatching {
dir.mkdirs()
entryFile(accountKey).writeText("$etag\n$json")
}
}
fun clear(accountKey: String) {
runCatching { entryFile(accountKey).delete() }
}
private fun entryFile(accountKey: String): File = File(dir, md5(accountKey) + ".json")
private fun md5(s: String): String =
MessageDigest.getInstance("MD5").digest(s.toByteArray())
.joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,9 @@
package ru.forbion.f7cloud.feature.notes
/**
* Скелет модуля «Заметки» (feature/notes), заведён лидом по пингу b-mob-notes «готов к
* регистрации» (mail/071/096). Реализация (Notes API v1 + ETag-кэш, план 123) зона
* b-mob-notes. Пункт меню «Заметки» пока веб-ссылка; лид переключит на нативный экран по
* готовности read-only ядра.
*/
internal object NotesModule
@@ -0,0 +1,98 @@
package ru.forbion.f7cloud.feature.notes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
/**
* Репозиторий Заметок: связывает сессию ([AuthSession]) [NotesApiClient] [NotesCache].
* Сеть на IO. Списки отдаются из ETag-кэша при 304 или отсутствии сети (offline-first
* read). Пишущие операции идут напрямую в API (проверяются юнит-тестами, не на forbion
* mail/073).
*/
class NotesRepository(
private val cache: NotesCache? = null,
private val apiFactory: (AuthSession) -> NotesApiClient = ::defaultApi,
) {
private fun accountKey(s: AuthSession) = "${s.serverUrl}|${s.davUserId ?: s.username}"
/**
* Список заметок (лёгкий, exclude=content). Пытается сеть с `If-None-Match` по кэш-ETag;
* при 304 или сетевой ошибке возвращает кэш (если он есть). Свежий ответ кладётся в кэш.
*/
suspend fun listNotes(session: AuthSession): List<Note> = withContext(Dispatchers.IO) {
val api = apiFactory(session)
val key = accountKey(session)
val cached = cache?.get(key)
try {
val res = api.list(excludeContent = true, ifNoneMatch = cached?.etag)
if (res.notModified) {
cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) }
return@withContext emptyList()
}
cache?.put(key, res.etag, notesToJson(res.notes))
res.notes
} catch (e: Exception) {
// offline / сеть недоступна → отдаём кэш, если он есть
cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) }
throw e
}
}
suspend fun getNote(session: AuthSession, id: Long): Note = withContext(Dispatchers.IO) {
apiFactory(session).get(id)
}
suspend fun createNote(session: AuthSession, content: String, category: String = ""): Note =
withContext(Dispatchers.IO) { apiFactory(session).create(content, category) }
/** Сохранение содержимого с If-Match; [NoteConflictException] на 412 (обрабатывает UI). */
suspend fun saveNote(session: AuthSession, note: Note): Note = withContext(Dispatchers.IO) {
apiFactory(session).saveContent(note)
}
suspend fun setFavorite(session: AuthSession, id: Long, favorite: Boolean): Note =
withContext(Dispatchers.IO) { apiFactory(session).setFavorite(id, favorite) }
suspend fun setCategory(session: AuthSession, id: Long, category: String): Note =
withContext(Dispatchers.IO) { apiFactory(session).setCategory(id, category) }
suspend fun renameNote(session: AuthSession, id: Long, title: String): Note =
withContext(Dispatchers.IO) { apiFactory(session).rename(id, title) }
suspend fun deleteNote(session: AuthSession, id: Long) = withContext(Dispatchers.IO) {
apiFactory(session).delete(id)
}
private fun notesToJson(notes: List<Note>): String {
val arr = JSONArray()
notes.forEach { n ->
arr.put(
JSONObject()
.put("id", n.id)
.put("etag", n.etag)
.put("readonly", n.readonly)
.put("title", n.title)
.put("category", n.category)
.put("content", n.content)
.put("favorite", n.favorite)
.put("modified", n.modified),
)
}
return arr.toString()
}
private companion object {
fun defaultApi(session: AuthSession): NotesApiClient {
val client = NetworkFactory.newAuthedClient(
username = session.username,
appPassword = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
return NotesApiClient(client, session.serverUrl)
}
}
}
@@ -0,0 +1,134 @@
package ru.forbion.f7cloud.feature.notes
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.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Юнит-тесты Notes API v1 через MockWebServer пишущие сценарии (create/save/If-Match/412)
* проверяются здесь, НЕ на боевом forbion (mail/073). Покрывают контракт из карты
* app-map/notes.md: exclude=content, ETag/If-None-Match, If-Match412 конфликт.
*/
class NotesApiClientTest {
private lateinit var server: MockWebServer
private lateinit var api: NotesApiClient
@Before
fun setUp() {
server = MockWebServer()
server.start()
api = NotesApiClient(OkHttpClient(), server.url("/").toString().trimEnd('/'))
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun `list parses notes and sends exclude=content plus ETag`() {
val body = """[{"id":7,"etag":"e7","title":"Заметка",""" +
""""category":"Работа","favorite":true,"modified":100}]"""
server.enqueue(MockResponse().setHeader("ETag", "coll-1").setBody(body))
val res = api.list(excludeContent = true, ifNoneMatch = "prev-etag")
assertFalse(res.notModified)
assertEquals("coll-1", res.etag)
assertEquals(1, res.notes.size)
assertEquals(7L, res.notes[0].id)
assertEquals("Заметка", res.notes[0].title)
assertTrue(res.notes[0].favorite)
val recorded = server.takeRequest()
assertEquals("/index.php/apps/notes/api/v1/notes?exclude=content", recorded.path)
assertEquals("prev-etag", recorded.getHeader("If-None-Match"))
}
@Test
fun `list returns notModified on 304`() {
server.enqueue(MockResponse().setResponseCode(304))
val res = api.list(ifNoneMatch = "coll-1")
assertTrue(res.notModified)
assertTrue(res.notes.isEmpty())
assertEquals("coll-1", res.etag)
}
@Test
fun `saveContent sends If-Match with quoted etag`() {
server.enqueue(MockResponse().setBody("""{"id":7,"etag":"e8","content":"new"}"""))
val saved = api.saveContent(Note(id = 7, etag = "e7", content = "new"))
assertEquals("e8", saved.etag)
val recorded = server.takeRequest()
assertEquals("PUT", recorded.method)
assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path)
assertEquals("\"e7\"", recorded.getHeader("If-Match"))
assertTrue(recorded.body.readUtf8().contains("\"content\""))
}
@Test
fun `saveContent on 412 rereads server version and throws conflict`() {
server.enqueue(MockResponse().setResponseCode(412)) // PUT → конфликт
server.enqueue(MockResponse().setBody("""{"id":7,"etag":"server","content":"srv"}""")) // GET перечит
val ex = assertThrows(NoteConflictException::class.java) {
api.saveContent(Note(id = 7, etag = "stale", content = "mine"))
}
assertEquals(7L, ex.noteId)
assertEquals("server", ex.serverNote?.etag)
assertEquals("srv", ex.serverNote?.content)
}
@Test
fun `create posts content and category`() {
server.enqueue(MockResponse().setBody("""{"id":9,"etag":"e9","content":"c","category":"Личное"}"""))
val note = api.create(content = "c", category = "Личное")
assertEquals(9L, note.id)
val recorded = server.takeRequest()
assertEquals("POST", recorded.method)
assertEquals("/index.php/apps/notes/api/v1/notes", recorded.path)
val body = recorded.body.readUtf8()
assertTrue(body.contains("\"content\""))
assertTrue(body.contains("Личное"))
}
@Test
fun `setFavorite puts favorite field without If-Match`() {
server.enqueue(MockResponse().setBody("""{"id":7,"favorite":true}"""))
val note = api.setFavorite(7, true)
assertTrue(note.favorite)
val recorded = server.takeRequest()
assertEquals("PUT", recorded.method)
assertNull(recorded.getHeader("If-Match"))
assertTrue(recorded.body.readUtf8().contains("\"favorite\""))
}
@Test
fun `delete issues DELETE on note id`() {
server.enqueue(MockResponse().setResponseCode(200))
api.delete(7)
val recorded = server.takeRequest()
assertEquals("DELETE", recorded.method)
assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path)
}
}
+9 -5
View File
@@ -2,24 +2,27 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:TalkComponents.kt$(message.attachmentFileName!!)</ID>
<ID>CyclomaticComplexMethod:TalkFormatting.kt$fun buildChatItems( messages: List&lt;TalkMessage&gt;, lastReadMessageId: Long, selfActorId: String, collapseSystemMessages: Boolean, ): List&lt;TalkChatItem&gt;</ID>
<ID>CyclomaticComplexMethod:TalkScreen.kt$@Composable fun TalkScreen( session: AuthSession, modifier: Modifier = Modifier, openRoomToken: String? = null, scrollToMessageId: Long? = null, chatsListRequest: Int = 0, pushSyncRequest: Int = 0, pushRoomToken: String? = null, onOpenRoomConsumed: () -&gt; Unit = {}, onRoomOpenStateChange: (Boolean) -&gt; Unit = {}, onOpenCalendar: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>CyclomaticComplexMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: () -&gt; Unit, onVoiceToggle: () -&gt; Unit, voiceRecording: Boolean, onRetry: () -&gt; Unit, onStartCall: () -&gt; Unit, onBackClick: () -&gt; Unit, onOpenRoomInfo: () -&gt; Unit, onScheduleMeeting: () -&gt; Unit, onToggleSystemMessages: () -&gt; Unit, onReply: (TalkMessage) -&gt; Unit, onReaction: (TalkMessage, String) -&gt; Unit, onDeleteMessage: (TalkMessage) -&gt; Unit, onEditMessage: (TalkMessage, String) -&gt; Unit, onMentionQuery: (String?) -&gt; Unit, onMentionSelected: (TalkMention) -&gt; Unit, onClearReply: () -&gt; Unit, onClearHighlight: () -&gt; Unit, )</ID>
<ID>ImportOrdering:TalkComponents.kt$import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable 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.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Surface 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import okhttp3.Credentials import ru.forbion.f7cloud.core.auth.AuthSession import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import coil.compose.AsyncImage import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.text.DateFormat import java.util.Calendar import java.util.Date import kotlin.math.abs</ID>
<ID>ImportOrdering:TalkScreen.kt$import android.Manifest import android.content.Context import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7TextButton import ru.forbion.f7cloud.feature.talknative.TalkNativeCallContext import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher</ID>
<ID>ImportOrdering:TalkViewModel.kt$import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.isActive import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker import ru.forbion.f7cloud.core.network.UnauthorizedException</ID>
<ID>CyclomaticComplexMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: () -&gt; Unit, onVoiceToggle: () -&gt; Unit, voiceRecording: Boolean, onRetry: () -&gt; Unit, onStartCall: () -&gt; Unit, onBackClick: () -&gt; Unit, onOpenRoomInfo: () -&gt; Unit, onScheduleMeeting: () -&gt; Unit, onToggleSystemMessages: () -&gt; Unit, onReply: (TalkMessage) -&gt; Unit, onReaction: (TalkMessage, String) -&gt; Unit, onDeleteMessage: (TalkMessage) -&gt; Unit, onEditMessage: (TalkMessage, String) -&gt; Unit, onForwardNoteToSelf: (TalkMessage) -&gt; Unit, onSetReminder: (TalkMessage, ReminderPreset) -&gt; Unit, onMentionQuery: (String?) -&gt; Unit, onMentionSelected: (TalkMention) -&gt; Unit, onClearReply: () -&gt; Unit, onClearHighlight: () -&gt; Unit, )</ID>
<ID>ImportOrdering:TalkComponents.kt$import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable 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.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Surface 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import okhttp3.Credentials import ru.forbion.f7cloud.core.auth.AuthSession import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import coil.compose.AsyncImage import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.text.DateFormat import java.util.Calendar import java.util.Date import kotlin.math.abs</ID>
<ID>ImportOrdering:TalkScreen.kt$import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch 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.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7TextButton import ru.forbion.f7cloud.feature.talknative.TalkNativeCallContext import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher</ID>
<ID>ImportOrdering:TalkViewModel.kt$import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.isActive import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker import ru.forbion.f7cloud.core.network.UnauthorizedException import java.util.Calendar</ID>
<ID>Indentation:TalkScreen.kt$ </ID>
<ID>InstanceOfCheckForException:TalkViewModel.kt$TalkViewModel$t is UnauthorizedException</ID>
<ID>LargeClass:TalkViewModel.kt$TalkViewModel : ViewModel</ID>
<ID>LongMethod:TalkComponents.kt$@Composable fun TalkComposer( session: AuthSession, value: String, onValueChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: (() -&gt; Unit)? = null, onVoiceToggle: (() -&gt; Unit)? = null, voiceRecording: Boolean = false, mentionCandidates: List&lt;TalkMention&gt; = emptyList(), onMentionQuery: ((String?) -&gt; Unit)? = null, onMentionSelected: ((TalkMention) -&gt; Unit)? = null, sending: Boolean, uploading: Boolean = false, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:TalkComponents.kt$@Composable fun TalkRoomRow( room: TalkRoom, session: AuthSession, modifier: Modifier = Modifier, onClick: () -&gt; Unit, onToggleFavorite: () -&gt; Unit = {}, onMarkUnread: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:TalkComponents.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TalkCreateRoomSheet( visible: Boolean, session: AuthSession, mode: CreateRoomMode, roomName: String, userSearchQuery: String, userSearchResults: List&lt;TalkUserCandidate&gt;, selectedUsers: List&lt;TalkUserCandidate&gt;, userSearchLoading: Boolean, creating: Boolean, onDismiss: () -&gt; Unit, onModeChange: (CreateRoomMode) -&gt; Unit, onRoomNameChange: (String) -&gt; Unit, onUserSearchChange: (String) -&gt; Unit, onUserToggle: (TalkUserCandidate) -&gt; Unit, onCreate: () -&gt; Unit, )</ID>
<ID>LongMethod:TalkScreen.kt$@Composable fun TalkScreen( session: AuthSession, modifier: Modifier = Modifier, openRoomToken: String? = null, scrollToMessageId: Long? = null, chatsListRequest: Int = 0, pushSyncRequest: Int = 0, pushRoomToken: String? = null, onOpenRoomConsumed: () -&gt; Unit = {}, onRoomOpenStateChange: (Boolean) -&gt; Unit = {}, onOpenCalendar: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: () -&gt; Unit, onVoiceToggle: () -&gt; Unit, voiceRecording: Boolean, onRetry: () -&gt; Unit, onStartCall: () -&gt; Unit, onBackClick: () -&gt; Unit, onOpenRoomInfo: () -&gt; Unit, onScheduleMeeting: () -&gt; Unit, onToggleSystemMessages: () -&gt; Unit, onReply: (TalkMessage) -&gt; Unit, onReaction: (TalkMessage, String) -&gt; Unit, onDeleteMessage: (TalkMessage) -&gt; Unit, onEditMessage: (TalkMessage, String) -&gt; Unit, onMentionQuery: (String?) -&gt; Unit, onMentionSelected: (TalkMention) -&gt; Unit, onClearReply: () -&gt; Unit, onClearHighlight: () -&gt; Unit, )</ID>
<ID>LongMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: () -&gt; Unit, onVoiceToggle: () -&gt; Unit, voiceRecording: Boolean, onRetry: () -&gt; Unit, onStartCall: () -&gt; Unit, onBackClick: () -&gt; Unit, onOpenRoomInfo: () -&gt; Unit, onScheduleMeeting: () -&gt; Unit, onToggleSystemMessages: () -&gt; Unit, onReply: (TalkMessage) -&gt; Unit, onReaction: (TalkMessage, String) -&gt; Unit, onDeleteMessage: (TalkMessage) -&gt; Unit, onEditMessage: (TalkMessage, String) -&gt; Unit, onForwardNoteToSelf: (TalkMessage) -&gt; Unit, onSetReminder: (TalkMessage, ReminderPreset) -&gt; Unit, onMentionQuery: (String?) -&gt; Unit, onMentionSelected: (TalkMention) -&gt; Unit, onClearReply: () -&gt; Unit, onClearHighlight: () -&gt; Unit, )</ID>
<ID>LoopWithTooManyJumpStatements:TalkRepository.kt$TalkRepository$for</ID>
<ID>LoopWithTooManyJumpStatements:TalkViewModel.kt$TalkViewModel$while</ID>
<ID>MatchingDeclarationName:TalkFilters.kt$TalkRoomFilter</ID>
<ID>MatchingDeclarationName:TalkFormatting.kt$TalkChatItem</ID>
<ID>MaximumLineLength:TalkCallActivity.kt$TalkCallActivity$ </ID>
<ID>MaximumLineLength:TalkComponents.kt$ </ID>
<ID>MultiLineIfElse:TalkComponents.kt$Modifier</ID>
<ID>MultiLineIfElse:TalkComponents.kt$Modifier.graphicsLayer { rotationZ = 180f }</ID>
<ID>NestedBlockDepth:TalkViewModel.kt$TalkViewModel$private fun resolveDisplayName(context: android.content.Context, uri: android.net.Uri, mimeType: String): String</ID>
@@ -28,6 +31,7 @@
<ID>NoUnusedImports:TalkRepository.kt$ru.forbion.f7cloud.feature.talk.TalkRepository.kt</ID>
<ID>NoUnusedImports:TalkScreen.kt$ru.forbion.f7cloud.feature.talk.TalkScreen.kt</ID>
<ID>NoUnusedImports:TalkShareActivity.kt$ru.forbion.f7cloud.feature.talk.TalkShareActivity.kt</ID>
<ID>UnusedParameter:TalkComponents.kt$session: AuthSession</ID>
<ID>UnusedParameter:TalkRoomInfoSheet.kt$session: AuthSession</ID>
<ID>Wrapping:TalkComponents.kt$;</ID>
<ID>Wrapping:TalkViewModel.kt$TalkUiState$(</ID>
@@ -804,6 +804,10 @@ fun TalkMessageActionSheet(
onEdit: (TalkMessage) -> Unit,
onDelete: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
onCopy: (TalkMessage) -> Unit,
onCopyLink: (TalkMessage) -> Unit,
onForwardNoteToSelf: (TalkMessage) -> Unit,
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
) {
if (message == null) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -831,6 +835,29 @@ fun TalkMessageActionSheet(
F7TextButton(text = emoji, onClick = { onReaction(message, emoji) })
}
}
if (!message.isSystemMessage) {
F7TextButton(text = "Копировать", onClick = { onCopy(message) })
F7TextButton(text = "Копировать ссылку", onClick = { onCopyLink(message) })
F7TextButton(
text = "Переслать в «Личные заметки»",
onClick = { onForwardNoteToSelf(message) },
)
Text(
"Напоминание:",
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextSecondary,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(
"Сегодня" to ReminderPreset.TODAY,
"Завтра" to ReminderPreset.TOMORROW,
"Выходные" to ReminderPreset.WEEKEND,
"Неделя" to ReminderPreset.NEXT_WEEK,
).forEach { (label, preset) ->
F7TextButton(text = label, onClick = { onSetReminder(message, preset) })
}
}
}
if (canModify) {
F7TextButton(text = "Изменить", onClick = { onEdit(message) })
F7TextButton(text = "Удалить", onClick = { onDelete(message) })
@@ -321,6 +321,24 @@ class TalkRepository {
}
}
/** Напоминание на сообщение (timestamp в секундах). OCS v1. */
suspend fun setReminder(session: AuthSession, roomToken: String, messageId: Long, timestamp: Long) {
val client = authedClient(session)
val url = spreedApi(session, "v1") + "/chat/$roomToken/$messageId/reminder?format=json"
val body = FormBody.Builder().add("timestamp", timestamp.toString()).build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code != 201 && !response.isSuccessful) {
error("Talk reminder failed: HTTP ${response.code}")
}
}
}
private fun authedClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
@@ -3,6 +3,7 @@ package ru.forbion.f7cloud.feature.talk
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
@@ -45,7 +46,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
@@ -292,6 +295,8 @@ fun TalkScreen(
onReaction = { msg, emoji -> vm.addReaction(session, msg.id, emoji) },
onDeleteMessage = { vm.deleteMessage(session, it.id) },
onEditMessage = { msg, text -> vm.editMessage(session, msg.id, text) },
onForwardNoteToSelf = { vm.forwardToNoteToSelf(session, it) },
onSetReminder = { msg, preset -> vm.setMessageReminder(session, msg.id, preset) },
onMentionQuery = { vm.setMentionQuery(session, it) },
onMentionSelected = { mention ->
draft = insertMentionIntoDraft(draft, mention)
@@ -430,6 +435,8 @@ private fun ChatContent(
onReaction: (TalkMessage, String) -> Unit,
onDeleteMessage: (TalkMessage) -> Unit,
onEditMessage: (TalkMessage, String) -> Unit,
onForwardNoteToSelf: (TalkMessage) -> Unit,
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
onMentionQuery: (String?) -> Unit,
onMentionSelected: (TalkMention) -> Unit,
onClearReply: () -> Unit,
@@ -441,6 +448,8 @@ private fun ChatContent(
var editDraft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val chatItems = remember(state.messages, state.openRoomLastReadMessageId, state.collapseSystemMessages) {
buildChatItems(
messages = state.messages,
@@ -507,6 +516,29 @@ private fun ChatContent(
onReaction(msg, emoji)
actionMessage = null
},
onCopy = { msg ->
clipboard.setText(AnnotatedString(msg.displayText))
Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show()
actionMessage = null
},
onCopyLink = { msg ->
val token = state.selectedRoomToken
if (token != null) {
clipboard.setText(AnnotatedString(TalkUrls.messageLink(session, token, msg.id)))
Toast.makeText(context, "Ссылка скопирована", Toast.LENGTH_SHORT).show()
}
actionMessage = null
},
onForwardNoteToSelf = { msg ->
onForwardNoteToSelf(msg)
Toast.makeText(context, "Переслано в «Личные заметки»", Toast.LENGTH_SHORT).show()
actionMessage = null
},
onSetReminder = { msg, preset ->
onSetReminder(msg, preset)
Toast.makeText(context, "Напоминание установлено", Toast.LENGTH_SHORT).show()
actionMessage = null
},
)
editingMessage?.let { message ->
@@ -19,4 +19,10 @@ object TalkUrls {
val base = session.serverUrl.trimEnd('/')
return "$base/index.php/core/preview?fileId=$fileId&x=$size&y=$size&a=1&mode=cover&forceIcon=0"
}
/** Веб-ссылка на сообщение (/call/<token>#message_<id>). */
fun messageLink(session: AuthSession, token: String, messageId: Long): String {
val base = session.serverUrl.trimEnd('/')
return "$base/index.php/call/$token#message_$messageId"
}
}
@@ -13,6 +13,15 @@ import kotlinx.coroutines.isActive
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
import java.util.Calendar
/** Пресеты напоминания по сообщению (как в веб-Talk). */
enum class ReminderPreset {
TODAY,
TOMORROW,
WEEKEND,
NEXT_WEEK,
}
data class TalkUiState(
val loading: Boolean = false,
@@ -594,6 +603,74 @@ class TalkViewModel(
}
}
/** Переслать сообщение в «Личные заметки» (note-to-self, type 6). */
fun forwardToNoteToSelf(session: AuthSession, message: TalkMessage) {
val text = message.displayText.trim()
if (text.isEmpty()) return
val noteRoom = _state.value.rooms.find { it.type == ROOM_TYPE_NOTE_TO_SELF }
if (noteRoom == null) {
_state.value = _state.value.copy(
error = "«Личные заметки» недоступны",
)
return
}
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.sendMessage(session, noteRoom.token, text) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message ?: "Не удалось переслать",
unauthorized = t is UnauthorizedException,
)
}
}
}
/** Напоминание на сообщение по пресету (сегодня/завтра/выходные/неделя). */
fun setMessageReminder(session: AuthSession, messageId: Long, preset: ReminderPreset) {
val token = _state.value.selectedRoomToken ?: return
val timestamp = reminderTimestamp(preset)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setReminder(session, token, messageId, timestamp) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message ?: "Не удалось поставить напоминание",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun reminderTimestamp(preset: ReminderPreset): Long {
val cal = Calendar.getInstance()
when (preset) {
ReminderPreset.TODAY -> cal.setTime(REMINDER_HOUR_EVENING)
ReminderPreset.TOMORROW -> {
cal.add(Calendar.DAY_OF_YEAR, 1)
cal.setTime(REMINDER_HOUR_MORNING)
}
ReminderPreset.WEEKEND -> {
while (cal.get(Calendar.DAY_OF_WEEK) != Calendar.SATURDAY) {
cal.add(Calendar.DAY_OF_YEAR, 1)
}
cal.setTime(REMINDER_HOUR_MORNING)
}
ReminderPreset.NEXT_WEEK -> {
do {
cal.add(Calendar.DAY_OF_YEAR, 1)
} while (cal.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY)
cal.setTime(REMINDER_HOUR_MORNING)
}
}
return cal.timeInMillis / MILLIS_IN_SECOND
}
private fun Calendar.setTime(hour: Int) {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
fun deleteMessage(session: AuthSession, messageId: Long) {
val token = _state.value.selectedRoomToken ?: return
viewModelScope.launch(Dispatchers.IO) {
@@ -749,5 +826,12 @@ class TalkViewModel(
companion object {
private const val BACKGROUND_POLL_DELAY_MS = 5_000L
private const val POLL_ERROR_DELAY_MS = 5_000L
/** spreed conversation type: note-to-self (Личные заметки). */
private const val ROOM_TYPE_NOTE_TO_SELF = 6
private const val MILLIS_IN_SECOND = 1000L
private const val REMINDER_HOUR_EVENING = 18
private const val REMINDER_HOUR_MORNING = 8
}
}
+1
View File
@@ -36,4 +36,5 @@ dependencies {
implementation libs.lifecycle.viewmodel.compose
implementation libs.coil.compose
implementation libs.coil.svg
testImplementation libs.junit
}
+7 -2
View File
@@ -7,15 +7,20 @@
<ID>ArgumentListWrapping:TasksComponents.kt$(model = tasksAsset(serverUrl, iconPath), contentDescription = null, modifier = Modifier.size(16.dp))</ID>
<ID>ArgumentListWrapping:TasksComponents.kt$(model = tasksAsset(serverUrl, iconPath), contentDescription = null, modifier = Modifier.size(18.dp))</ID>
<ID>ArgumentListWrapping:TasksComponents.kt$(serverUrl, iconPath)</ID>
<ID>ArgumentListWrapping:TasksGrouping.kt$TasksGrouping$(compareByDescending&lt;TaskItem&gt; { it.dueLocalDate() ?: LocalDate.MIN } .thenBy { it.summary.lowercase() })</ID>
<ID>ArgumentListWrapping:TasksViewModel.kt$TasksViewModel$(saving = false, error = t.message ?: "Не удалось удалить", unauthorized = t is UnauthorizedException)</ID>
<ID>ImportOrdering:TasksScreen.kt$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.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.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler</ID>
<ID>Indentation:TasksComponents.kt$ </ID>
<ID>Indentation:TasksScreen.kt$ </ID>
<ID>LongMethod:TasksComponents.kt$@Composable fun TaskDetailSheet( visible: Boolean, serverUrl: String, task: TaskItem?, saving: Boolean, onDismiss: () -&gt; Unit, onSwipeBack: () -&gt; Unit = onDismiss, onToggleComplete: () -&gt; Unit, onSave: (summary: String, due: String, priority: Int, description: String) -&gt; Unit, onDelete: () -&gt; Unit, )</ID>
<ID>LongMethod:TasksComponents.kt$@Composable fun TasksRow( task: TaskItem, serverUrl: String, showMeta: Boolean = true, onToggleComplete: () -&gt; Unit, onOpen: () -&gt; Unit, modifier: Modifier = Modifier, onAddSubtask: (() -&gt; Unit)? = null, onDelete: (() -&gt; Unit)? = null, )</ID>
<ID>LongMethod:TasksScreen.kt$@Composable fun TasksScreen( session: AuthSession, modifier: Modifier = Modifier, createRequest: Int = 0, openListSlug: String? = null, showListsRequest: Int = 0, onOpenListConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>MaximumLineLength:TasksComponents.kt$ </ID>
<ID>MaximumLineLength:TasksViewModel.kt$TasksViewModel$ </ID>
<ID>NoUnusedImports:TasksComponents.kt$ru.forbion.f7cloud.feature.tasks.TasksComponents.kt</ID>
<ID>UnusedParameter:TasksComponents.kt$onCreateCollapse: () -&gt; Unit</ID>
<ID>UnusedPrivateProperty:TasksComponents.kt$private val TasksTagBg = Color(0xFFDAEDD6)</ID>
<ID>UnusedPrivateProperty:TasksComponents.kt$private val TasksTagText = Color(0xFF28A413)</ID>
<ID>Wrapping:TasksGrouping.kt$TasksGrouping$(</ID>
<ID>Wrapping:TasksComponents.kt$;</ID>
<ID>Wrapping:TasksViewModel.kt$TasksViewModel$(</ID>
</CurrentIssues>
</SmellBaseline>
@@ -237,11 +237,23 @@ fun TasksListNavView(
selectedHref: String?,
onListClick: (TaskListItem) -> Unit,
modifier: Modifier = Modifier,
smartKeys: List<SmartListKey> = emptyList(),
smartCounts: Map<SmartListKey, Int> = emptyMap(),
onSmartClick: (SmartListKey) -> Unit = {},
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(smartKeys, key = { "smart-${it.name}" }) { key ->
SmartCollectionRow(
serverUrl = serverUrl,
title = key.title,
icon = key.icon,
count = smartCounts[key] ?: 0,
onClick = { onSmartClick(key) },
)
}
item {
Row(
modifier = Modifier
@@ -304,6 +316,59 @@ fun TasksListNavView(
}
}
@Composable
private fun SmartCollectionRow(
serverUrl: String,
title: String,
icon: String,
count: Int,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(TasksFieldBg)
.tasksFieldShadow()
.clickable { onClick() }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
AsyncImage(
model = tasksAsset(serverUrl, icon),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text(
title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (count > 0) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.PrimaryLight)
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
count.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = F7Colors.PrimaryDark,
)
}
}
}
}
@Composable
fun TasksGroupHeader(
title: String,
@@ -951,17 +1016,12 @@ fun TasksSettingsSheet(
lists: List<TaskListItem>,
defaultListHref: String?,
onDefaultListChange: (String?) -> Unit,
visibility: SmartListVisibility,
onSmartModeChange: (SmartListKey, SmartListMode) -> Unit,
onDismiss: () -> Unit,
onSwipeBack: () -> Unit = onDismiss,
) {
if (!visible) return
val visibilityOptions = listOf("Показывать", "Скрывать")
var importantVisible by remember { mutableStateOf(visibilityOptions.first()) }
var todayVisible by remember { mutableStateOf(visibilityOptions.first()) }
var weekVisible by remember { mutableStateOf(visibilityOptions.first()) }
var allVisible by remember { mutableStateOf(visibilityOptions.first()) }
var currentVisible by remember { mutableStateOf(visibilityOptions.first()) }
var completedVisible by remember { mutableStateOf(visibilityOptions.first()) }
var defaultMenuOpen by remember { mutableStateOf(false) }
Dialog(
@@ -1034,23 +1094,15 @@ fun TasksSettingsSheet(
HorizontalDivider(color = F7Colors.Border)
SmartListSettingRow(serverUrl, "important-gray.svg", "Важное", importantVisible) {
importantVisible = it
}
SmartListSettingRow(serverUrl, "calendar-today-gray.svg", "Сегодня", todayVisible) {
todayVisible = it
}
SmartListSettingRow(serverUrl, "calendar-nextweek-gray.svg", "На неделе", weekVisible) {
weekVisible = it
}
SmartListSettingRow(serverUrl, "calendar-icon-gray.svg", "Все", allVisible) {
allVisible = it
}
SmartListSettingRow(serverUrl, "current-gray.svg", "Текущие", currentVisible) {
currentVisible = it
}
SmartListSettingRow(serverUrl, "check-done-gray.svg", "Завершенные", completedVisible) {
completedVisible = it
Text("Видимость умных списков", fontSize = 14.sp, fontWeight = FontWeight.Medium)
SmartListKey.entries.forEach { key ->
SmartListSettingRow(
serverUrl = serverUrl,
iconPath = key.icon,
title = key.title,
mode = visibility.mode(key),
onModeChange = { onSmartModeChange(key, it) },
)
}
}
}
@@ -1062,11 +1114,16 @@ private fun SmartListSettingRow(
serverUrl: String,
iconPath: String,
title: String,
selected: String,
onSelect: (String) -> Unit,
mode: SmartListMode,
onModeChange: (SmartListMode) -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
val options = listOf("Показывать", "Скрывать")
val options = listOf(
SmartListMode.VISIBLE to "Показывать",
SmartListMode.HIDDEN to "Скрывать",
SmartListMode.AUTO to "Автоматически",
)
val currentLabel = options.firstOrNull { it.first == mode }?.second ?: "Автоматически"
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
AsyncImage(model = tasksAsset(serverUrl, iconPath), contentDescription = null, modifier = Modifier.size(18.dp))
@@ -1084,7 +1141,7 @@ private fun SmartListSettingRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(selected)
Text(currentLabel)
AsyncImage(
model = tasksAsset(serverUrl, "chevron-down-16.svg"),
contentDescription = null,
@@ -1092,11 +1149,11 @@ private fun SmartListSettingRow(
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
options.forEach { option ->
options.forEach { (optionMode, label) ->
DropdownMenuItem(
text = { Text(option) },
text = { Text(label) },
onClick = {
onSelect(option)
onModeChange(optionMode)
menuOpen = false
},
)
@@ -24,8 +24,10 @@ object TasksGrouping {
val sorted = if (sortAscending) {
tasks.sortedWith(compareBy({ it.dueLocalDate() ?: LocalDate.MAX }, { it.summary.lowercase() }))
} else {
tasks.sortedWith(compareByDescending<TaskItem> { it.dueLocalDate() ?: LocalDate.MIN }
.thenBy { it.summary.lowercase() })
tasks.sortedWith(
compareByDescending<TaskItem> { it.dueLocalDate() ?: LocalDate.MIN }
.thenBy { it.summary.lowercase() }
)
}
val grouped = linkedMapOf<String, MutableList<TaskItem>>()
val colors = linkedMapOf<String, Color>()
@@ -65,6 +67,13 @@ fun TaskItem.dueLocalDate(): LocalDate? {
}.getOrNull()
}
fun TaskItem.startLocalDate(): LocalDate? {
if (startRaw.isBlank()) return null
return runCatching {
LocalDate.parse(startRaw.take(8), DateTimeFormatter.BASIC_ISO_DATE)
}.getOrNull()
}
fun TaskItem.isOverdue(): Boolean {
val date = dueLocalDate() ?: return false
return !isCompleted && date.isBefore(LocalDate.now())
@@ -0,0 +1,29 @@
package ru.forbion.f7cloud.feature.tasks
import android.content.Context
/**
* Персист настроек Задач в SharedPreferences (по образцу `CalendarPrefsStore`).
* Сейчас режим видимости умных коллекций (3-state), переживает перезапуск.
*/
class TasksPrefsStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun getVisibility(): SmartListVisibility {
val modes = SmartListKey.entries.mapNotNull { key ->
val raw = prefs.getString(keyMode(key), null) ?: return@mapNotNull null
runCatching { SmartListMode.valueOf(raw) }.getOrNull()?.let { key to it }
}.toMap()
return SmartListVisibility.of(modes)
}
fun setMode(key: SmartListKey, mode: SmartListMode) {
prefs.edit().putString(keyMode(key), mode.name).apply()
}
companion object {
private const val PREFS = "f7_tasks_prefs"
private fun keyMode(key: SmartListKey) = "smart_mode::${key.name}"
}
}
@@ -30,6 +30,12 @@ data class TaskItem(
val listHref: String,
val listName: String,
val parentUid: String = "",
/**
* Сырой DTSTART в формате YYYYMMDD[THHMMSS[Z]] (как dueRaw). Нужен умным спискам
* «Текущие»/«Сегодня»/«На неделе» (веб-Tasks учитывает и start, и due).
* Приходит из `DavTask.startRaw` (core читает DTSTART в `parseVTodo`).
*/
val startRaw: String = "",
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
@@ -162,6 +168,7 @@ class TasksRepository {
listHref = calendarHref,
listName = calendarName,
parentUid = parentUid,
startRaw = startRaw,
)
private fun calendarSlug(href: String): String {
@@ -10,8 +10,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
@@ -38,7 +39,8 @@ fun TasksScreen(
onOpenListConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val vm: TasksViewModel = viewModel()
val context = LocalContext.current
val vm: TasksViewModel = viewModel(factory = TasksViewModel.Factory(context))
val state by vm.state.collectAsState()
// Родитель для создаваемой подзадачи (открывает диалог ввода названия).
var subtaskParent by remember { mutableStateOf<TaskItem?>(null) }
@@ -90,12 +92,15 @@ fun TasksScreen(
CircularProgressIndicator(color = F7Colors.Primary)
}
}
state.selectedListHref == null -> {
!state.showTaskList -> {
TasksListNavView(
serverUrl = session.serverUrl,
lists = state.lists,
selectedHref = null,
onListClick = { vm.openList(session, it.href) },
smartKeys = state.visibleSmartKeys,
smartCounts = state.smartCounts,
onSmartClick = { vm.openSmartList(session, it) },
)
}
else -> {
@@ -153,6 +158,8 @@ fun TasksScreen(
lists = state.lists,
defaultListHref = state.defaultListHref,
onDefaultListChange = vm::setDefaultList,
visibility = state.smartVisibility,
onSmartModeChange = vm::setSmartMode,
onDismiss = vm::closeSettings,
onSwipeBack = vm::closeSettings,
)
@@ -0,0 +1,125 @@
package ru.forbion.f7cloud.feature.tasks
import java.time.LocalDate
/**
* Умные (виртуальные) списки Задач агрегируют задачи ПО ВСЕМ реальным спискам.
* Семантика строго по веб-Tasks Nextcloud (согласовано с лидом, mail/070 п.2):
*
* - **Важное** (starred): незавершённые с высоким приоритетом (VTODO PRIORITY 14).
* - **Сегодня**: незавершённые, у которых start ИЛИ due не в будущем (today или раньше,
* т.е. включая просроченные). Задачи вовсе без дат сюда НЕ попадают.
* - **На неделе**: то же, но горизонт конец текущей недели (today .. today+6).
* - **Все**: все незавершённые (включая будущий start).
* - **Текущие** (current): незавершённые, у которых start НЕ в будущем (уже стартовали
* или без start). Отличие от «Все» исключает задачи с будущим start.
* - **Завершенные**: завершённые.
*
* Чистая логика без Android/сети покрыта юнит-тестами (`TasksSmartListsTest`).
*
* NB: start приходит из core (`DavTask.startRaw` DTSTART в `parseVTodo`). Границу окна
* «неделя» и точное участие start сверить на веб-версии glb/апстриме nextcloud/tasks v0.17.1.
*/
enum class SmartListKey(val title: String, val icon: String) {
IMPORTANT("Важное", "important-gray.svg"),
TODAY("Сегодня", "calendar-today-gray.svg"),
WEEK("На неделе", "calendar-nextweek-gray.svg"),
ALL("Все", "calendar-icon-gray.svg"),
CURRENT("Текущие", "current-gray.svg"),
COMPLETED("Завершенные", "check-done-gray.svg"),
}
/**
* Режим видимости умной коллекции в панели как в веб-Tasks (server `show` 0/1/2):
* [HIDDEN] всегда скрыта · [VISIBLE] всегда видима · [AUTO] видима только при count > 0.
*/
enum class SmartListMode { HIDDEN, VISIBLE, AUTO }
/**
* Настройка видимости умных коллекций (шит «Параметры задач»). Хранит только НЕ-дефолтные
* режимы для компактной сериализации в SharedPreferences. Умолчание [DEFAULT_MODE].
*/
data class SmartListVisibility(
private val modes: Map<SmartListKey, SmartListMode> = emptyMap(),
) {
fun mode(key: SmartListKey): SmartListMode = modes[key] ?: DEFAULT_MODE
fun withMode(key: SmartListKey, mode: SmartListMode): SmartListVisibility =
SmartListVisibility(
if (mode == DEFAULT_MODE) modes - key else modes + (key to mode),
)
/** Только заданные вручную (не-дефолтные) режимы — для персиста. */
fun explicitModes(): Map<SmartListKey, SmartListMode> = modes
companion object {
/** Умолчание: AUTO (показывать при непустом счётчике) — сверить с сервером live. */
val DEFAULT_MODE = SmartListMode.AUTO
val DEFAULT = SmartListVisibility()
fun of(modes: Map<SmartListKey, SmartListMode>): SmartListVisibility =
SmartListVisibility(modes.filterValues { it != DEFAULT_MODE })
}
}
object TasksSmartLists {
/** Высокий приоритет VTODO (1 — наивысший, 4 — ещё «важный»); 0 = не задан. */
private val HIGH_PRIORITY = 1..4
/** Горизонт списка «На неделе»: сегодня плюс 6 дней (окно текущей недели). */
private const val WEEK_HORIZON_DAYS = 6L
/** Попадает ли [task] в умный список [key] при опорной дате [today]. */
fun matches(key: SmartListKey, task: TaskItem, today: LocalDate): Boolean = when (key) {
SmartListKey.IMPORTANT -> !task.isCompleted && task.priority in HIGH_PRIORITY
SmartListKey.TODAY -> !task.isCompleted && task.dueOrStartOnOrBefore(today)
SmartListKey.WEEK -> !task.isCompleted && task.dueOrStartOnOrBefore(today.plusDays(WEEK_HORIZON_DAYS))
SmartListKey.ALL -> !task.isCompleted
SmartListKey.CURRENT -> !task.isCompleted && task.hasStarted(today)
SmartListKey.COMPLETED -> task.isCompleted
}
/** Задачи умного списка [key], сохраняя исходный порядок [tasks]. */
fun filter(key: SmartListKey, tasks: List<TaskItem>, today: LocalDate): List<TaskItem> =
tasks.filter { matches(key, it, today) }
/** Число задач в умном списке (для бейджа в панели). */
fun count(key: SmartListKey, tasks: List<TaskItem>, today: LocalDate): Int =
tasks.count { matches(key, it, today) }
/** Счётчики всех коллекций сразу (по агрегату задач всех списков). */
fun counts(tasks: List<TaskItem>, today: LocalDate): Map<SmartListKey, Int> =
SmartListKey.entries.associateWith { key -> count(key, tasks, today) }
/**
* Коллекции для показа в панели (в порядке enum) с учётом режима видимости и счётчиков:
* VISIBLE всегда, AUTO при count > 0, HIDDEN никогда.
*/
fun visibleKeys(
visibility: SmartListVisibility,
counts: Map<SmartListKey, Int>,
): List<SmartListKey> = SmartListKey.entries.filter { key ->
when (visibility.mode(key)) {
SmartListMode.VISIBLE -> true
SmartListMode.HIDDEN -> false
SmartListMode.AUTO -> (counts[key] ?: 0) > 0
}
}
/** Уже стартовала: нет start, либо start сегодня/в прошлом. */
private fun TaskItem.hasStarted(today: LocalDate): Boolean {
val start = startLocalDate() ?: return true
return !start.isAfter(today)
}
/**
* Есть start ИЛИ due, и хотя бы один из них не позже [limit].
* Задача без обеих дат не попадает в «Сегодня»/«На неделе».
*/
private fun TaskItem.dueOrStartOnOrBefore(limit: LocalDate): Boolean {
val start = startLocalDate()
val due = dueLocalDate()
if (start == null && due == null) return false
return (start != null && !start.isAfter(limit)) || (due != null && !due.isAfter(limit))
}
}
@@ -1,6 +1,8 @@
package ru.forbion.f7cloud.feature.tasks
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -31,10 +33,30 @@ data class TasksUiState(
val error: String? = null,
val unauthorized: Boolean = false,
val detailTask: TaskItem? = null,
// Умные коллекции (P1): агрегат задач всех списков + режим видимости + выбранная коллекция.
val allTasks: List<TaskItem> = emptyList(),
val smartVisibility: SmartListVisibility = SmartListVisibility.DEFAULT,
val selectedSmartKey: SmartListKey? = null,
) {
val selectedList: TaskListItem?
get() = lists.firstOrNull { it.href == selectedListHref }
/** Счётчики умных коллекций по агрегату всех списков (для бейджей в панели). */
val smartCounts: Map<SmartListKey, Int>
get() = TasksSmartLists.counts(allTasks, LocalDate.now())
/** Умные коллекции, видимые в панели (режим + счётчик). */
val visibleSmartKeys: List<SmartListKey>
get() = TasksSmartLists.visibleKeys(smartVisibility, smartCounts)
/** Заголовок текущего экрана списка: реальный список или умная коллекция. */
val currentTitle: String
get() = selectedSmartKey?.title ?: selectedList?.displayName ?: ""
/** Показывать экран задач (реальный список ИЛИ умная коллекция выбраны). */
val showTaskList: Boolean
get() = selectedListHref != null || selectedSmartKey != null
private fun matchesSearch(task: TaskItem): Boolean {
val q = searchQuery.trim()
return q.isEmpty() ||
@@ -68,9 +90,11 @@ data class TasksUiState(
}
class TasksViewModel(
context: Context,
private val repository: TasksRepository = TasksRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(TasksUiState())
private val prefsStore = TasksPrefsStore(context.applicationContext)
private val _state = MutableStateFlow(TasksUiState(smartVisibility = prefsStore.getVisibility()))
val state: StateFlow<TasksUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
@@ -107,6 +131,8 @@ class TasksViewModel(
)
}
selected?.let { loadTasks(session, it) }
// Агрегат всех списков — для умных коллекций и их счётчиков.
loadAllTasks(session)
}
.onFailure { t ->
_state.update {
@@ -124,6 +150,7 @@ class TasksViewModel(
_state.update {
it.copy(
selectedListHref = listHref,
selectedSmartKey = null,
// Мгновенно из кэша, если есть — иначе пусто до загрузки.
tasks = TasksCache.cachedTasks(listHref).orEmpty(),
searchQuery = "",
@@ -135,10 +162,33 @@ class TasksViewModel(
loadTasks(session, listHref)
}
/** Открыть умную коллекцию (агрегат всех списков, отфильтрованный по [key]). */
fun openSmartList(session: AuthSession, key: SmartListKey) {
_state.update {
it.copy(
selectedSmartKey = key,
selectedListHref = null,
tasks = TasksSmartLists.filter(key, it.allTasks, LocalDate.now()),
searchQuery = "",
detailTask = null,
createInputExpanded = false,
createInputText = "",
)
}
loadAllTasks(session)
}
/** Сменить режим видимости умной коллекции (шит настроек) + персист. */
fun setSmartMode(key: SmartListKey, mode: SmartListMode) {
prefsStore.setMode(key, mode)
_state.update { it.copy(smartVisibility = it.smartVisibility.withMode(key, mode)) }
}
fun closeList() {
_state.update {
it.copy(
selectedListHref = null,
selectedSmartKey = null,
tasks = emptyList(),
detailTask = null,
createInputExpanded = false,
@@ -218,7 +268,11 @@ class TasksViewModel(
priority: Int,
parentUid: String = "",
) {
val listHref = _state.value.selectedListHref ?: return
// В умной коллекции реального списка нет — создаём в список по умолчанию.
val listHref = _state.value.selectedListHref
?: _state.value.defaultListHref
?: _state.value.lists.firstOrNull()?.href
?: return
if (summary.isBlank()) {
_state.update { it.copy(error = "Введите название задачи") }
return
@@ -248,7 +302,7 @@ class TasksViewModel(
createInputText = "",
)
}
loadTasks(session, listHref)
reloadCurrent(session)
}.onFailure { t ->
_state.update {
it.copy(
@@ -266,7 +320,6 @@ class TasksViewModel(
runCatching {
repository.toggleComplete(session, task, !task.isCompleted)
}.onSuccess {
val listHref = _state.value.selectedListHref
val detail = _state.value.detailTask
if (detail?.uid == task.uid) {
_state.update {
@@ -276,7 +329,7 @@ class TasksViewModel(
))
}
}
listHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}.onFailure { t ->
_state.update {
it.copy(
@@ -309,7 +362,7 @@ class TasksViewModel(
)
}.onSuccess {
_state.update { it.copy(saving = false, detailTask = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}.onFailure { t ->
_state.update {
it.copy(
@@ -331,7 +384,7 @@ class TasksViewModel(
_state.update {
it.copy(saving = false, detailTask = it.detailTask?.takeIf { d -> d.uid != task.uid })
}
_state.value.selectedListHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}
.onFailure { t ->
_state.update {
@@ -353,7 +406,7 @@ class TasksViewModel(
runCatching { repository.deleteTask(session, task) }
.onSuccess {
_state.update { it.copy(saving = false, detailTask = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}
.onFailure { t ->
_state.update {
@@ -376,7 +429,7 @@ class TasksViewModel(
runCatching { repository.deleteCompletedTasks(session, completed) }
.onSuccess {
_state.update { it.copy(deletingCompleted = false, completedExpanded = false) }
loadTasks(session, listHref)
reloadCurrent(session)
}
.onFailure { t ->
_state.update {
@@ -391,8 +444,46 @@ class TasksViewModel(
}
fun refreshTasks(session: AuthSession) {
_state.value.selectedListHref?.let { loadTasks(session, it) }
?: load(session)
val href = _state.value.selectedListHref
when {
href != null -> loadTasks(session, href)
_state.value.selectedSmartKey != null -> loadAllTasks(session)
else -> load(session)
}
}
/** Перезагрузить текущий вид: реальный список и/или агрегат (если открыта умная коллекция). */
private fun reloadCurrent(session: AuthSession) {
val href = _state.value.selectedListHref
href?.let { loadTasks(session, it) }
if (_state.value.selectedSmartKey != null || href == null) {
loadAllTasks(session)
}
}
/** Загрузить задачи ВСЕХ списков в агрегат (умные коллекции + счётчики). */
private fun loadAllTasks(session: AuthSession) {
val lists = _state.value.lists
if (lists.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
val aggregate = mutableListOf<TaskItem>()
lists.forEach { list ->
runCatching { repository.loadTasks(session, list.href) }
.onSuccess { tasks ->
TasksCache.putTasks(list.href, tasks)
aggregate += tasks
}
}
_state.update { st ->
st.copy(
allTasks = aggregate,
// Если открыта умная коллекция — переотфильтровать её содержимое.
tasks = st.selectedSmartKey?.let {
TasksSmartLists.filter(it, aggregate, LocalDate.now())
} ?: st.tasks,
)
}
}
}
private fun loadTasks(session: AuthSession, listHref: String) {
@@ -417,4 +508,10 @@ class TasksViewModel(
}
}
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
TasksViewModel(context.applicationContext) as T
}
}
@@ -0,0 +1,246 @@
package ru.forbion.f7cloud.feature.tasks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/**
* Семантика умных списков Задач по веб-Tasks Nextcloud (mail/070 п.2).
* Опорная дата фиксирована тесты детерминированы, от сети/Android не зависят.
*/
class TasksSmartListsTest {
private val today: LocalDate = LocalDate.of(2026, 7, 12)
private fun stamp(date: LocalDate?): String =
date?.format(DateTimeFormatter.BASIC_ISO_DATE).orEmpty()
private fun task(
uid: String = "u",
summary: String = "t",
priority: Int = 0,
status: String = "NEEDS-ACTION",
percent: Int = 0,
due: LocalDate? = null,
start: LocalDate? = null,
) = TaskItem(
uid = uid,
href = "/$uid.ics",
etag = "",
summary = summary,
status = status,
due = stamp(due),
dueRaw = stamp(due),
priority = priority,
percentComplete = percent,
description = "",
listHref = "/list/",
listName = "Список",
startRaw = stamp(start),
)
private fun matches(key: SmartListKey, task: TaskItem) =
TasksSmartLists.matches(key, task, today)
// --- Важное (priority 1..4, незавершённые) ---
@Test
fun important_highPriorityIncomplete_matches() {
assertTrue(matches(SmartListKey.IMPORTANT, task(priority = 1)))
assertTrue(matches(SmartListKey.IMPORTANT, task(priority = 4)))
}
@Test
fun important_mediumLowOrNone_excluded() {
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 0)))
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 5)))
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 9)))
}
@Test
fun important_completedHighPriority_excluded() {
assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 1, status = "COMPLETED")))
}
// --- Сегодня (start|due <= today, есть дата, незавершённые) ---
@Test
fun today_dueTodayOrOverdue_matches() {
assertTrue(matches(SmartListKey.TODAY, task(due = today)))
assertTrue(matches(SmartListKey.TODAY, task(due = today.minusDays(1))))
}
@Test
fun today_dueFuture_excluded() {
assertFalse(matches(SmartListKey.TODAY, task(due = today.plusDays(1))))
}
@Test
fun today_startTodayNoDue_matches() {
assertTrue(matches(SmartListKey.TODAY, task(start = today)))
}
@Test
fun today_noDates_excluded() {
assertFalse(matches(SmartListKey.TODAY, task()))
}
@Test
fun today_completedDueToday_excluded() {
assertFalse(matches(SmartListKey.TODAY, task(due = today, status = "COMPLETED")))
}
// --- На неделе (start|due <= today+6) ---
@Test
fun week_withinHorizon_matches() {
assertTrue(matches(SmartListKey.WEEK, task(due = today.plusDays(6))))
assertTrue(matches(SmartListKey.WEEK, task(due = today.minusDays(3))))
}
@Test
fun week_beyondHorizon_excluded() {
assertFalse(matches(SmartListKey.WEEK, task(due = today.plusDays(7))))
}
// --- Все (все незавершённые, включая будущий start/due) ---
@Test
fun all_incompleteRegardlessOfDates_matches() {
assertTrue(matches(SmartListKey.ALL, task()))
assertTrue(matches(SmartListKey.ALL, task(start = today.plusDays(30))))
assertTrue(matches(SmartListKey.ALL, task(due = today.plusDays(30))))
}
@Test
fun all_completed_excluded() {
assertFalse(matches(SmartListKey.ALL, task(status = "COMPLETED")))
}
// --- Текущие (start не в будущем или без start; незавершённые) ---
@Test
fun current_noStart_matches() {
assertTrue(matches(SmartListKey.CURRENT, task()))
}
@Test
fun current_startPastOrToday_matches() {
assertTrue(matches(SmartListKey.CURRENT, task(start = today)))
assertTrue(matches(SmartListKey.CURRENT, task(start = today.minusDays(5))))
}
@Test
fun current_futureStart_excluded() {
assertFalse(matches(SmartListKey.CURRENT, task(start = today.plusDays(1))))
}
@Test
fun current_vs_all_futureStartTask() {
// Ключевое отличие: задача с будущим start есть в «Все», но нет в «Текущие».
val future = task(start = today.plusDays(3))
assertTrue(matches(SmartListKey.ALL, future))
assertFalse(matches(SmartListKey.CURRENT, future))
}
// --- Завершенные ---
@Test
fun completed_byStatus_matches() {
assertTrue(matches(SmartListKey.COMPLETED, task(status = "COMPLETED")))
}
@Test
fun completed_byPercent100_matches() {
assertTrue(matches(SmartListKey.COMPLETED, task(percent = 100)))
}
@Test
fun completed_incomplete_excluded() {
assertFalse(matches(SmartListKey.COMPLETED, task()))
}
// --- filter/count по набору ---
@Test
fun filterAndCount_overMixedSet() {
// a: Важное+Сегодня+Неделя+Все+Текущие. b: Все+Текущие (будущий due, старт не в будущем).
// c: только Все (будущий start исключает из Текущие). d: Завершенные.
val tasks = listOf(
task(uid = "a", priority = 2, due = today),
task(uid = "b", due = today.plusDays(10)),
task(uid = "c", start = today.plusDays(2)),
task(uid = "d", status = "COMPLETED", priority = 1),
)
assertEquals(listOf("a"), TasksSmartLists.filter(SmartListKey.IMPORTANT, tasks, today).map { it.uid })
assertEquals(listOf("a"), TasksSmartLists.filter(SmartListKey.TODAY, tasks, today).map { it.uid })
assertEquals(3, TasksSmartLists.count(SmartListKey.ALL, tasks, today))
assertEquals(listOf("a", "b"), TasksSmartLists.filter(SmartListKey.CURRENT, tasks, today).map { it.uid })
assertEquals(1, TasksSmartLists.count(SmartListKey.COMPLETED, tasks, today))
}
// --- Видимость (3-state: Скрыта/Видима/Авто) ---
@Test
fun visibility_defaultModeIsAuto() {
val v = SmartListVisibility.DEFAULT
SmartListKey.entries.forEach { assertEquals(SmartListMode.AUTO, v.mode(it)) }
}
@Test
fun visibility_setAndClearMode() {
val v = SmartListVisibility.DEFAULT
.withMode(SmartListKey.COMPLETED, SmartListMode.HIDDEN)
.withMode(SmartListKey.ALL, SmartListMode.VISIBLE)
assertEquals(SmartListMode.HIDDEN, v.mode(SmartListKey.COMPLETED))
assertEquals(SmartListMode.VISIBLE, v.mode(SmartListKey.ALL))
// Возврат в дефолт (AUTO) — режим не хранится (компактный персист).
val cleared = v.withMode(SmartListKey.ALL, SmartListMode.AUTO)
assertEquals(SmartListMode.AUTO, cleared.mode(SmartListKey.ALL))
assertEquals(setOf(SmartListKey.COMPLETED), cleared.explicitModes().keys)
}
@Test
fun visibleKeys_autoShowsOnlyNonEmpty() {
val counts = mapOf(
SmartListKey.IMPORTANT to 2,
SmartListKey.TODAY to 0,
SmartListKey.WEEK to 0,
SmartListKey.ALL to 5,
SmartListKey.CURRENT to 5,
SmartListKey.COMPLETED to 0,
)
// Всё по умолчанию AUTO → показываем только непустые, в порядке enum.
assertEquals(
listOf(SmartListKey.IMPORTANT, SmartListKey.ALL, SmartListKey.CURRENT),
TasksSmartLists.visibleKeys(SmartListVisibility.DEFAULT, counts),
)
}
@Test
fun visibleKeys_visibleAlwaysHiddenNever() {
val counts = SmartListKey.entries.associateWith { 0 }
val v = SmartListVisibility.DEFAULT
.withMode(SmartListKey.ALL, SmartListMode.VISIBLE) // видима даже при 0
.withMode(SmartListKey.CURRENT, SmartListMode.HIDDEN)
val visible = TasksSmartLists.visibleKeys(v, counts)
assertEquals(listOf(SmartListKey.ALL), visible) // остальные AUTO+0 скрыты, CURRENT HIDDEN
}
@Test
fun counts_overMixedSet() {
val tasks = listOf(
task(uid = "a", priority = 2, due = today),
task(uid = "b", status = "COMPLETED"),
task(uid = "c"),
)
val counts = TasksSmartLists.counts(tasks, today)
assertEquals(1, counts[SmartListKey.IMPORTANT])
assertEquals(2, counts[SmartListKey.ALL]) // a, c незавершённые
assertEquals(1, counts[SmartListKey.COMPLETED])
assertEquals(2, counts[SmartListKey.CURRENT]) // a, c (без будущего старта)
}
}
+4
View File
@@ -7,6 +7,7 @@ composeBom = "2025.02.00"
coreKtx = "1.15.0" # был дрейф 1.13.1 / 1.15.0
lifecycle = "2.8.7" # был дрейф 2.8.1 / 2.8.7
coil = "2.7.0" # был дрейф 2.6.0 / 2.7.0
media3 = "1.4.1" # нативный медиа-слой (видео/аудио) — ExoPlayer
coroutines = "1.10.1" # был дрейф 1.8.1 / 1.10.1
navigationCompose = "2.8.9"
room = "2.7.2"
@@ -38,6 +39,9 @@ lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-ru
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+1
View File
@@ -29,6 +29,7 @@ include(":feature:talk-native")
include(":feature:calendar")
include(":feature:contacts")
include(":feature:tasks")
include(":feature:notes")
include(":feature:deck")
include(":feature:mail")
include(":feature:f7support")