174 Commits

Author SHA1 Message Date
b-mob-contacts 6b98c5a0ba feat(contacts): P1 шаг 2/2 — правка/удаление в UI (Room v4→5 href/etag/raw, форма, delete-подтверждение) 2026-07-13 07:59:00 +00:00
b-mob-deck 73762d80c5 feat(deck): меню колонки — Переименовать / Удалить список (P1 diff)
DeckRepository.renameStack/deleteStack (PUT/DELETE /stacks/{id}); VM renameStack/deleteStack;
overflow-меню (⋯) в шапке колонки → DropdownMenu; диалог переименования (префилл) +
DeckConfirmDialog для удаления. Веб-паритет меню Stack.
2026-07-13 07:53:54 +00:00
b-mob-notes e3725b7339 docs(app-map): notes.diff итер.1b — P1-UI done (Ff 42.8%), STATUS обновлён
read-only ядро собрано: список/поиск/пусто/редактор-автосейв/конфликт-диалог.
16/42 Ф в коде+UI. Fd 0% (ждёт девайс). Дальше — регистрация экрана у лида.
2026-07-13 07:48:28 +00:00
b-mob-notes fb120d5795 feat(notes): P1-UI — ViewModel + экран списка + редактор с автосейвом и диалогом конфликта
- NotesViewModel: список (избранное↑, поиск), открытие/создание/сохранение, toggle favorite,
  удаление; конфликт 412 поднимается в состояние и разрешается ЯВНЫМ выбором (Оставить мою/
  Версия сервера), не молчаливо (mail/071)
- NotesScreen: F7ModuleScreen + поиск + пусто-состояние Welcome + список F7ListCard + диалог
  конфликта (F7AlertDialog)
- NoteEditorScreen: markdown-поле (plain, rich/preview — P2), debounce-автосейв → PUT If-Match,
  read-only состояние, действия избранное/удалить/готово

Верифицировано изолированно (worktree origin/main): compileDebugKotlin + 7/7 tests + detekt OK.
Закрывает UI-часть P1 read-only ядра. Дальше — регистрация экрана у лида (app/).
2026-07-13 07:44:52 +00:00
b-mob-calendar 7be35490e4 docs(app-map): calendar.diff — P1+BYDAY done, Appointments=n-a (Ff 64.0/Fd 80.4 по 5 экранам) + STATUS 2026-07-13 07:44:34 +00:00
b-mob-calendar 492af6157e fix(calendar): BYDAY=1MO — RecurrenceRule хранит/сериализует ординал (был потерян в toRrule) + разворот n-го дня месяца в CalendarRecurrence +4 теста (P1 добивка, mail/101) 2026-07-13 07:43:19 +00:00
b-mob-conf 2819359b2b docs(app-map): talk.diff/STATUS — Переслать-в-комнату done (Ff 41.3%), меню сообщения ~закрыто 2026-07-13 07:41:08 +00:00
b-mob-deck b818032f68 app-map: deck.diff — бейдж комментариев [done] (Ff_app 23.1%), STATUS 2026-07-13 07:40:44 +00:00
b-mob-contacts af25ee8e6d docs(app-map): contacts.diff уточнён по mail/102 (Команды=n-a, Ff 31.2%) + P1 ядро landed (bc1a497) 2026-07-13 07:40:01 +00:00
b-mob-deck 9d726f4549 feat(deck): бейдж числа комментариев на карточке (P1 diff — CardBadges веба)
parseCard читает commentsCount (модель Card отдаёт), DeckCardRow показывает «💬 N».
Green compile; detekt модуля 103=103 к HEAD (без новых замечаний).
2026-07-13 07:39:50 +00:00
b-mob-conf 01198a4d14 feat(talk): меню сообщения — Переслать… в любую комнату (пикер) [P1] 2026-07-13 07:39:48 +00:00
b-mob-calendar e7d0a5a9ee feat(calendar): P1 разворот повторений — CalendarRecurrence (RRULE-оккуренции на лету) + eventOccursOnDay RRULE-осознан +11 тестов; refresh detekt-baseline (v0.5.144-долг) 2026-07-13 07:36:19 +00:00
b-mob-conf 1c1e9c89e2 docs(app-map): talk.diff/STATUS — Ответить лично done (Ff 40.7%) 2026-07-13 07:34:11 +00:00
b-mob-contacts bc1a4970ef feat(contacts): ядро P1 — updateContact/deleteContact (If-Match) + VCardEditor round-trip + 10 юнит-тестов 2026-07-13 07:34:11 +00:00
b-mob-notes f7c0c721a2 docs(app-map): notes.diff итер.1a — слой данных done (Ff 21.7%), STATUS обновлён
Скелет заведён лидом (133d80d), P1 слой данных реализован+юнит-тест (8a8f1cf):
8/42 Ф закрыты в коде (create/get/list/favorite/category/rename/delete/save-If-Match/конфликт).
Fd=0% (UI не собран). Дальше — P1-UI.
2026-07-13 07:34:06 +00:00
b-mob-deck 48ee65dd9a app-map: deck.diff — «Назначить мне/снять» [done] (Ff_app 22.6%), STATUS: P1 начат 2026-07-13 07:33:21 +00:00
b-mob-conf a60fd78a28 feat(talk): меню сообщения — Ответить лично; рефактор оверлеев (ChatContent проще) [P1] 2026-07-13 07:33:13 +00:00
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
b-dev-mobile 91aaaba5a0 chore: версия 0.5.144 (152) + CHANGELOG сводного релиза 2026-07-10 22:28:15 +00:00
b-dev-mobile e6a71de7a6 feat(calendar): пакет функционала — событие 1:1 с веб, вложения, повторение, шаринг, free/busy
- Русские месяцы в шапке; нативный DatePickerDialog (material3 крашился)
- 403 при PUT события: учёт supported-calendar-component-set (VTODO-коллекции
  не принимают VEVENT) + sabre-precondition в тексте ошибки
- Окно события по _calendar-edit-full-fields.css: pill-поля, крестик, зелёная
  галочка-сохранение, нативные пикеры даты/времени, высота 94% экрана
- Talk-беседа к событию: выбор существующей / создание публичной|приватной
- Вложения (ATTACH+FILENAME/FMTTYPE): «из файлов» (WebDAV-пикер) или загрузка
  с устройства в /Calendar; round-trip при редактировании события
- Кастомное повторение (_calendar-repeat-modal.css): интервал+частота, дни
  недели кружками, окончание никогда/до даты/N раз ↔ RRULE (parse/build/описание)
- Многодневные события (endDate) и несколько напоминаний (VALARM list)
- Управление календарём (✎ в сайдбаре): PROPPATCH имя/цвет, DELETE,
  шаринг через DAV oc:invite + OCS sharees (польз./группы, чтение/редакт.)
- Discard-диалог при закрытии редактора с несохранёнными изменениями
- Занятость участников: iTIP VFREEBUSY через DAV outbox, конфликты красным
2026-07-10 22:28:15 +00:00
b-dev-mobile 4b000d3b27 feat(files): веб-сессия Nextcloud для .whiteboard в WebView
Доска (whiteboard) открывается через веб-viewer, которому мало Basic-auth:
Vue-XHR ходят по session-cookie+CSRF. Теперь перед загрузкой web-файла
логинимся формой /login app-паролем (NextcloudWebSession, обходит 2FA),
переносим cookie в CookieManager и грузим страницу нативно — для web-файлов
отключено OkHttp-проксирование (оно теряло тело POST/PROPFIND → «нет файлов»).
Collabora-путь не тронут (WOPI-токен самодостаточен).
+NetworkFactory.newBaseBuilder — билдер на общем пуле без auth.
2026-07-10 22:26:38 +00:00
b-dev-mobile f70b1861bd chore: версия 0.5.137 (145)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:12:19 +00:00
b-dev-mobile 6cdef16952 fix(files): .whiteboard белый экран — не прятать chrome (сносился #body-user)
Скрипт HIDE_F7CLOUD_CHROME_JS делал hide(#body-user) — это ВЕСЬ контейнер
приложения Nextcloud; на Collabora-странице документ в отдельном
#documents-content, а на Files-странице (whiteboard) внутри #body-user
лежит и сам viewer → удалялось всё → белый экран. Теперь скрипт
инжектится ТОЛЬКО для Collabora (collaboraBaseUrl не пуст); для web-файлов
chrome не трогаем — доска рисуется в штатном viewer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:12:19 +00:00
b-dev-mobile c7af5f7d11 chore: версия 0.5.136 (144)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:44:31 +00:00
b-dev-mobile 2ec2115be7 fix(files): .md теперь реально открывается; whiteboard без ложного таймаута
- .md/.txt не открывались: их не было в openable-гейте FilesScreen
  (тап игнорировался). Добавил MarkdownFiles.isMarkdown в гейт → нативный
  редактор открывается.
- .whiteboard ловил «Редактор не ответил вовремя»: editorVisible ставил
  только Collabora-JS-мост, которого у whiteboard нет → всегда таймаут.
  Теперь для web-файлов (collaboraBaseUrl пуст) готовность = onPageFinished,
  таймаут не срабатывает; страница доски догружается (WebView уже с
  cookies/DOM-storage/mixed-content/websocket).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:44:11 +00:00
b-dev-mobile 3e5d0d4892 chore: версия 0.5.135 (143)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:43:18 +00:00
b-dev-mobile c0703c3537 feat(files): нативный редактор markdown/текста; whiteboard уточнён
- .md/.markdown/.txt/.org/.log теперь открываются в НАТИВНОМ редакторе
  (MarkdownRepository GET/PUT по WebDAV) — мгновенно, без тяжёлого веб-Text,
  который «вечно грузился» (тянул весь Files UI). Экран MarkdownEditorScreen:
  правка в TextField, предпросмотр md с базовым рендером (заголовки/жирный/
  курсив/код/списки/цитаты), сохранение по WebDAV, кнопка активна только
  при изменениях, back закрывает.
- .whiteboard остаётся веб-вьюером (/f/<id>): доска — Excalidraw-канвас
  с реалтайм-бэкендом (whiteboard.f7cloud.ru + JWT), нативно не реализуется;
  открывается через штатный viewer Files, как в вебе.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:43:01 +00:00
b-dev-mobile 40e3a4da5c chore: версия 0.5.134 (142)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:18:28 +00:00
b-dev-mobile a96eda5b7c feat(files): открытие .md/.whiteboard + предпрогрев WebView для office
- .md/.markdown/.txt/.whiteboard теперь открываются: WebOpenableFiles →
  каноническая ссылка /index.php/f/<fileId> в авторизованном WebView,
  сервер сам направляет в Text/Whiteboard (office остаётся на Collabora).
  Тап по таким файлам в списке разрешён.
- Предпрогрев: OfficeWarmup.warmWebViewEngine создаёт и уничтожает пустой
  WebView при входе в Файлы — первое создание WebView в процессе тянет
  провайдер Chromium (сотни мс), из-за чего первый docx/xlsx открывался
  медленно; теперь движок прогрет заранее.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:17:41 +00:00
b-dev-mobile 1e39b2e2fe chore: версия 0.5.133 (141)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:52:20 +00:00
b-dev-mobile e1e0427066 fix: иконки шторки в единой круглой рамке; кэш Задач для быстрого показа
- Шторка: КАЖДАЯ иконка теперь в белом круге с рамкой и мягкой тенью,
  все одного размера (глиф внутри 56% рамки) — как на мобильном сайте
  (было: плоские глифы без рамки, разного размера).
- Задачи грузятся быстрее: TasksCache (в памяти на время процесса) —
  списки и задачи по спискам показываются мгновенно из кэша при
  повторном открытии раздела/списка, сеть обновляет в фоне. Гонки
  учтены: результат применяется, только если пользователь ещё на этом
  списке.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:52:01 +00:00
b-dev-mobile 25cfbe9bdc chore: версия 0.5.132 (140)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:32:34 +00:00
b-dev-mobile c9340ab2ab feat(tasks): вложенные задачи (подзадачи) + per-task меню «⋯»
Паритет веб-версии Задач:
- CalDAV: RELATED-TO;RELTYPE=PARENT в модели/парсере/билдере VTODO
  (DavTask.parentUid); createTask принимает parentUid; updateTask и
  toggleComplete сохраняют связь с родителем.
- TasksRepository/VM: createSubtask(parent), deleteTask(task) для любой
  задачи (не только открытой детали).
- UI: у задачи меню «⋯» → «Добавить вложенную задачу» / «Удалить задачу»;
  подзадачи рисуются под родителем с отступом 24dp; диалог ввода названия
  подзадачи. Подзадачи исключены из верхнего уровня (не дублируются).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:32:19 +00:00
b-dev-mobile cdb2471445 chore: версия 0.5.131 (139)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:19:26 +00:00
b-dev-mobile a5ebcd4d80 fix(tasks): стрелка нижней панели открывает панель списков задач
Раньше на Задачах стрелка «назад» уводила на предыдущий раздел (Файлы).
Теперь она закрывает открытый список и показывает панель списков задач
(TasksListNavView: Все/Текущие/конкретные списки) — как сайдбар папок
в Почте/Файлах/Календаре. Проброшен showListsRequest → vm.closeList().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:19:26 +00:00
b-dev-mobile 374e8838e8 chore: версия 0.5.130 (138)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:14:08 +00:00
b-dev-mobile 47b2638b2b fix(menu): состав шторки 1:1 (Поддержка + Личный кабинет) + иконки внешних сайтов
По разметке сайта (layout.user.php):
- Добавлены пункты «Поддержка» (AppTab.Support, icon-header-f7support)
  после Заметок и «Личный кабинет» (открывает нативную ProfileSheet,
  lk-header-icon) перед Уведомлениями — их не хватало.
- Иконки внешних сайтов (Bitrix/1C) строились из имени файла как есть →
  битая ссылка → глобус. Теперь путь /index.php/apps/external/img/{icon},
  как на сайте.
- Coil: добавлен Basic-auth интерсептор для хоста сервера — серверные
  картинки (иконки external и пр.) требуют логина, без него был 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:13:56 +00:00
b-dev-mobile b8e08d1a97 chore: версия 0.5.129 (137)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:44:09 +00:00
b-dev-mobile fd341cb22a fix: нижняя панель 1:1 с сайтом (Задачи + mobile-иконки); Deck открывается устойчиво
Нижняя панель (по layout.user.php сайта): средняя кнопка = ЗАДАЧИ
(была Карточки) — по решению владельца «как на сайте»; иконки из
набора mobile-*-icon (плоский конверт mobile-mail-icon вместо
конверта-с-@, mobile-tasks-icon, mobile-spreed-icon вебкамера,
mobile-burger-icon), локально/офлайн; размеры 25dp (app-link) как
на сайте, стрелка «назад» 18dp. Карточки остаются в шторке-меню.

Deck: loadBoardDetail больше не рушится, если GET /boards/{id}
(метки/права) не отвечает — стеки грузятся всё равно (вероятная
причина «доска не открывается» на устройстве). Заголовок/метки/права
подставляются best-effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:43:56 +00:00
b-dev-mobile b8a40afe3a chore: версия 0.5.128 (136) + CHANGELOG
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:04:14 +00:00
b-dev-mobile 5daaab732d fix: левая стрелка нижней панели = «назад» для разделов без сайдбара; Deck GET без OCS-заголовка
- onNavBackClick: для Карточек/Конференций/Контактов/Задач/Поддержки
  (нет сайдбара) стрелка теперь popTabHistory() → предыдущий раздел
  (была мёртвой, else -> Unit).
- DeckRepository.getJson вернул к прежнему рабочему виду (без
  OCS-APIRequest), чтобы исключить регресс чтения досок; запись
  (sendJson) заголовок сохраняет.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:03:24 +00:00
b-dev-mobile cac214340e fix(menu): плоские иконки шторки как на мобильном сайте (images/menu, не glass)
Шторка показывала glass-кружки (frosted 49px из images/header/*-glass),
а мобильный сайт рисует ПЛОСКИЕ двухцветные иконки из images/menu/*.svg
(зелёный #70B62B + чёрный контур). Забандлил menu-*.svg (files/calendar/
contacts/cards/notes/settings + notifications из not-menu-icon-big),
переключил пункты приложений и нативные Уведомления/Настройки на них.
Неиспользуемые glass/header-ассеты удалены (нижняя панель грузит иконки
с сервера).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 05:58:00 +00:00
b-dev-mobile 0a36abe695 chore: версия 0.5.127 (135) + CHANGELOG
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 05:34:51 +00:00
b-dev-mobile 4aa732846d feat(deck): полный функционал Карточек — создание/правка/перемещение/метки
Было: только чтение (доски → список названий карточек). Стало —
полноценная работа через внутренний Deck API (/api/v1.0, тот же, что
у веб-версии; app-password + OCS-APIRequest, CSRF не требуется):
- Доска: колонки-стеки со счётчиком; карточки показывают метки-чипы
  (цвета доски), срок, исполнителей, чекбокс «выполнено».
- Карточка-деталь (лист): правка названия/описания, срок через
  DatePicker (+сброс), переключение done, метки доски тапом,
  перемещение в другую колонку, архив, удаление.
- Создание карточки (+ в колонке) и колонки (+ Колонка в шапке).
- Права: если PERMISSION_EDIT=false — всё только для чтения.
- Модели расширены (DeckLabel/description/duedate/assignees/stackId);
  VM перечитывает доску после каждого действия. Поля сверены с
  Card.php на forbion. Запись на устройстве не тестировалась (в прод
  Deck не писал) — проверить при ревью.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 05:33:28 +00:00
b-dev-mobile 7f95b07666 design(menu): шторка и нижняя панель 1:1 по живому forbion
Косяк: оба меню расходились с боевым forbion (_header.css).
Шторка (.app-menu--mobile): убраны скругление верха и ручка-хендл
(в живом их нет), высота — по контенту почти на весь экран (была
½ экрана), нижний отступ 90 под панель, сетка 3 кол. с gap 8 (было 20),
фон #FBFBFB. Иконки-glass теперь ЛОКАЛЬНЫЕ (assets/menu/, офлайн);
Уведомления/Настройки — фирменные mobile-*-glass вместо Material-иконок
в круге.
Нижняя панель (.header__mobile-bottom): фикс. пилюля 328×61, паддинг 3,
кнопки 55 с фоном #FBFBFB и рамкой #E6E6E6 (была градиентная), раскладка
space-between; ВОЗВРАЩЕНА подсветка активного — текущий раздел и открытый
бургер красятся полупрозрачным зелёным градиентом 104° (как в forbion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 05:22:23 +00:00
b-dev-mobile 92e0e9690b chore: версия 0.5.126 (134) + CHANGELOG релиза
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:47:17 +00:00
b-dev-mobile 2cf5977dd4 feat(mail): фильтры — на официальный API, ОБЩИЕ с вебом, полное управление
Заменил собственный sieve-слой (маркированные блоки F7MOBILE) на
штатный API NC Mail GET/PUT /apps/mail/api/filter/{accountId} — тот
самый, которым пользуется веб-модалка «Новый фильтр». Теперь:
- видны ВСЕ правила (созданные и в вебе, и в приложении);
- любое правило можно включить/выключить и удалить с телефона;
- простые правила (1 условие) редактируются инлайн с префиллом формы;
  сложные помечаются «правится в вебе», их JSON сохраняется целиком
  (MailFilterRule держит raw и не теряет незнакомые поля);
- сериализацию в sieve делает сервер (FilterBuilder, секция
  «### Nextcloud Mail: Filters ###») — совместимость гарантирована.
Действия конструктора: переместить в папку / пометить прочитанным
(addsystemflag \\Seen). MailFilterRuleTest — 3 юнит-теста зелёные.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:43:42 +00:00
b-dev-mobile ab864aa7d7 feat(mail): sieve-фильтры — список и конструктор «Новый фильтр»
Паритет живого веба (модалка «Новый фильтр»):
- API: GET/PUT /apps/mail/api/sieve/active/{accountId} (MailRepository).
- Наши правила живут в маркированных блоках # F7MOBILE-BEGIN/END
  внутри активного скрипта; пользовательские/веб-правила НЕ трогаем,
  перед каждой записью скрипт перечитывается (не затираем изменения
  из веба). require ["fileinto","imap4flags"] — в начале скрипта.
- Конструктор: если [От/Тема/Кому] содержит X → [переместить в папку /
  пометить прочитанным / удалить]; список правил с удалением.
- Вход: Настройки почты → аккаунт → «Фильтры».
- MailSieveScript покрыт юнит-тестами (roundtrip, сохранение чужого
  скрипта, экранирование кавычек, все действия) — 4 зелёных.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:15:17 +00:00
b-dev-mobile 2df46667e0 feat(mail): «Создать задачу» и «Создать событие» из письма
Паритет живого веба: пункты в меню действий письма (иконки темы
create-task-black/calendar-black) → модалки в стиле темы (заголовок
18/600, плашки-выбор списка/календаря #F5F5F5 r10, дата через
DatePicker, «Весь день», время ЧЧ:ММ). Префилл: название = тема
письма, описание = «Из письма от <отправитель>». Сохранение через
существующие TasksRepository.createTask / CalendarRepository.saveEvent
(CalDAV) — feature:mail теперь зависит от feature:tasks и
feature:calendar (цикла нет).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:07:21 +00:00
b-dev-mobile 9ec3114506 design(mail): пустая папка — цветная иконка почты по живой теме
mail-icon-color.svg 64dp (забандлена, офлайн) + текст по центру
(был голый серый текст).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 03:25:51 +00:00
b-dev-mobile 8204e605e2 design(mail): модалка меток по живой теме
Заголовок 18/600; строки тегов — плашки #F5F5F5 r10 (minHeight 36),
имя тега — цветная пилюля MailTagChip (был голый текст).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 03:24:42 +00:00
b-dev-mobile 84a1ea9f27 design(calendar): деталь события и пилюли по живой теме
Заголовок события 18/600 (модалки темы); пилюля события в неделе —
#ECF9DE с рамкой #70B62B r4 (был полупрозрачный зелёный r6). Лейблы
детали уже серые, кнопки — дизайн-система (градиентные пилюли) —
совпадали. Наш bottom-sheet и список дня — осознанная моб. адаптация.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 03:22:53 +00:00
b-dev-mobile 753b574e1a design(login): фон, локальный логотип, QR-кнопка по живой теме
_page-login.css: фон страницы #E5EFE8 (был белый); big-forbion.svg
забандлен в assets/login/ — логотип виден офлайн и до ввода адреса
сервера (грузился с сервера); кнопка QR — «ссылка-кнопка» 40dp
с фоном #F0F1F4 r8 и чёрным текстом 14 (была текстовая). Карточка
440/r8/24, заголовок 18/600, поля — уже совпадали.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 03:20:42 +00:00
b-dev-mobile 3da61da45d design(mail): композер 1:1 по живой теме — чипы получателей и поля
_mobile-mail.css 3283–4067:
- Поля Кому/Копия/Скрытая: подтверждённые адреса — ЧИПЫ-пилюли 26dp
  (фон #70B62B 12%, рамка 25%, текст 13/18, крестик 14 чёрный-50%),
  хвост — инлайн-ввод 14sp; значение остаётся строкой с запятыми,
  VM/parseRecipients не тронуты. Поле авто-высотой (FlowRow), minHeight 40.
- Заголовок «Новое сообщение» 16/600 (был Bold titleMedium).
- Поля темы/получателей: текст и плейсхолдер 14/500 (был 15).
- Тулбар форматирования: r8 (был 10), фон #FDFDFD, тень темы, паддинг 4.
Кнопка «Отправить» 44dp на всю ширину и скрепка 32 в углу редактора
уже совпадали.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 02:58:13 +00:00
b-dev-mobile 999b52eb80 chore: версия 0.5.125 (133)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:26:12 +00:00
b-dev-mobile 4c7566bbb2 design(contacts): типографика строк по живой теме
_mobile-contacts.css: имя 16/500 (был SemiBold), детали (email/телефон)
14/500 серым (были 12). Задачи/Карточки сверены: уже в токенах темы
(строки/заголовки/F7ListCard r8) — правок не потребовалось.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:25:19 +00:00
b-dev-mobile 65805a15af design(calendar): месячная сетка 1:1 по живой теме
_calendar-month-view-mobile.css: месяц — белая карточка r12 с рамкой
и паддингом 12; дни недели — круги 40 #F5F5F5 (16/500, были пилюли);
ячейки — карточки #FDFDFD r4 с тенью, высота 120 (были gridline 66),
зазор 4; номер дня 14/500 справа-сверху; «сегодня» — зелёная плашка
40×24 r4 с белым числом (был круг); события — пилюли #ECF9DE с рамкой
#70B62B r4, текст 10/12, до 3 в ячейке; вне месяца — серые.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:20:26 +00:00
b-dev-mobile bc9a31a026 design(talk): пузыри чата и вложения 1:1 по живой теме
_mobile-spreed.css: автор — внутрь пузыря (12/600 чёрный, было зелёное
имя над пузырём), паддинг пузыря 8×10 (был 8×12), зазор сообщений 4px;
вложение-файл — круглая иконка типа 49dp из локальных assets/filetypes
+ имя 14/500 #3F3F3F (был смайлик 📄 зелёным). Радиус r8 и цвета
пузырей (#FDFDFD/#E0F8C9) уже совпадали.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:15:48 +00:00
b-dev-mobile bbfb6ae424 design(mail): список, навигация, sender-card 1:1 по живой теме
_mobile-mail.css:
- Карточка письма: r8 (было 12), фон #FDFDFD, нижняя граница #E6E6E6 +
  тень #CBCBCB, отправитель 16/500, тема 14/600 чёрный-50%, ДОБАВЛЕНА
  превью-строка письма (14/500 чёрный-50%, 1 строка), дата 14/500,
  зазор между карточками 2px (был 8).
- Навигация: панель 90% ширины, фон #FBFBFB, имя папки 14/500 (без
  жирнения активной), счётчик 16/500 серым без фона, разделитель
  #E6E6E6 под шапкой аккаунта.
- Sender-card сообщения: аватар 40 (был 44), имя 16/500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:09:11 +00:00
b-dev-mobile 1bc568b059 design(files): навигация, панель выделения, свойства, поповеры — 1:1 по теме
_mobile-files.css, оставшиеся секции:
- Левая панель: ширина 90%, фон #FBFBFB, отступы темы, градиентная
  кнопка «Создать или загрузить» (40dp, 104°, белые папка/плюс/разделитель)
  — кнопка создания из шапки уехала сюда, как в живом; строки 40dp/r8,
  иконка 20, текст 16/20, активная #ECF9DE, без «⋮»; квота по центру серым.
- Панель выделения: 40dp, чекбокс темы «выбрать все» (провязан
  selectAllVisible/clearSelection), «Выбрать все» чёрным + «(Выбран N)»
  серым, пилюля «Действия» 25dp.
- Панель свойств: на весь экран без скруглений, фон #FBFBFB, крестик 40,
  заголовок 18/600 + звезда темы 20 (зелёная/серая), чип владельца-пилюля
  25dp (аватар 16), «дата / размер» серым справа; табы-пилюли 30dp
  с иконками темы (comment/people-icon-black), активная #ECF9DE
  с рамкой #70B62B 20%.
- Поповеры действий (строка и массовые): белые r8 с рамкой и тенью 8%,
  пункты 25dp/r6, иконка 16, текст 14.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:58:28 +00:00
b-dev-mobile 6895f1bffd design(files): главный экран 1:1 по живой теме forbion (_mobile-files.css)
Источник — мобильный CSS боевого сервера (копия в design-reference).
- 9 фирменных SVG хрома забандлены в assets/chrome/ (офлайн): чекбоксы
  16dp (контур/зелёный), звезда избранного, ⋮, лупа, дом, тумблеры вида,
  цветная папка (закрыт TODO «папка с сервера»).
- Строка списка: высота 44dp, чекбокс темы 16 в колонке 44, иконка 30
  (было 24) + звезда-оверлей у избранных, колонки размера (56, центр)
  и даты (88, вправо, относительное время как в вебе), ⋮ 16 в колонке 32,
  без скругления, выбранная — #ECF9DE.
- Тумблер вида: круг 40, иконка 16 из темы. Крошки: дом 16 (home-bottom),
  все сегменты серые 14/20, шеврон 16. Фильтр-чипы: пилюли 25dp,
  minWidth 86, текст чёрный, фон #FBFBFB + мягкая тень (как в теме).
- Карточка сетки: радиус 12, иконка 80 (было 48), чекбокс/звезда 16 по
  углам, ⋮ 16, фон #FBFBFB.
- Поиск: фон #FBFBFB, локальная лупа из CSS темы, крестик-очистка 20dp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:07:59 +00:00
b-dev-mobile e5bbf0e123 design: семантика ошибок по боевой теме forbion
Живой мобильный CSS сервера (themes/forbion, _base.css) — теперь источник
истины (решение владельца «копировать один в один»): Error #D74642,
ErrorText #D42722, ErrorBg #FFE2E2. Базовый Red #FF7A66 брендбука
сохранён для тегов.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:45:12 +00:00
b-dev-mobile 2758d07e47 fix: чёткий векторный логотип на сплэше
Сплэш показывал лаунчер-иконку: растр 432px тянулся в круг ~1000px —
мыло, легаси-квадрат криво резался маской. Теперь — VectorDrawable
из фирменного logo-header.svg темы forbion (точные цвета #151515/#70B62B),
глиф отцентрирован в безопасной зоне круга (2/3), резкий на любом dpi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:33:04 +00:00
b-dev-mobile c6732546f1 chore: версия 0.5.124 (132)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:53:20 +00:00
b-dev-mobile dd5bdbc177 push: 30-с таймаут входящего звонка + уведомление «Пропущенный звонок»
Не отвеченный звонок звонит 30 секунд, после чего снимается сам:
рингтон/уведомление гасятся, показывается «Пропущенный звонок»
(на локскрине — обезличенно, после разблокировки — с названием
комнаты; тап открывает комнату). Полноэкранный входящий закрывается
по тому же событию. Страховочный alarm поднимает процесс и снимает
звонок, даже если система убила приложение во время звонка.
Принятые/отклонённые звонки «Пропущенный» не показывают.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:47:53 +00:00
b-dev-mobile 47534ceb59 refactor: session/auth в MainViewModel (Этап 2 п.10, ч.2)
Верхнеуровневый session-жизненный цикл вынесен из AppScaffold в MainViewModel
(AndroidViewModel): держит session (StateFlow), login()=save+set,
logout()=ревокация app password + OfficeWarmup.clear + WebViewPool.dispose +
AuthStore.clear. AppScaffold читает vm.session.collectAsState(), forceLogout=
{vm.logout()}. Бонус: сессия переживает пересоздание Activity (поворот) —
раньше жила в remember и перечитывалась из AuthStore. UI-тумблеры (menuOpen,
sidebar/settings-флаги, pending* deep-link) осознанно оставлены в composable —
это чистое UI-состояние, не бизнес-логика.
2026-07-09 15:02:21 +00:00
b-dev-mobile 8c90e83cef refactor: LoginScreen вынесен из AppScaffold в свой файл (Этап 2 п.10, ч.1)
LoginScreen (~234 строки) был вложен в файл-«бог» AppScaffold. Вынесен в
LoginScreen.kt (internal, тот же пакет) — чистый перенос, поведение не
менялось. AppScaffold: 925→691 строк, убрано 22 импорта, использовавшихся
только в LoginScreen. Первый безопасный слой разбора AppScaffold.
2026-07-09 14:42:55 +00:00
b-dev-mobile a674721353 ux: единый экран ошибки F7ErrorState + retry (Этап 2 п.12)
Новый F7ErrorState: центрированная иконка + сообщение + «Повторить»
вместо россыпи красного текста. F7ModuleScreen при ошибке без данных
показывает его (+ новый onErrorRetry — retry без хедер-кнопки ↻).
Подключено: Контакты/Конференции/Карточки/Поддержка (через F7ModuleScreen),
Файлы (onErrorRetry=refreshCurrent), Почта (пустой список + ошибка →
F7ErrorState, убран верхний баннер-текст). Retry везде дергает load/refresh.
2026-07-09 14:08:36 +00:00
b-dev-mobile 8f7eaff611 ux: SplashScreen на холодном старте (Этап 2 п.12)
androidx core-splashscreen 1.0.1: тема Theme.F7.Splash (фон #FBFBFB как
F7Colors.Background + иконка ic_launcher), postSplashScreenTheme — прежняя
тема приложения. installSplashScreen() в MainActivity.onCreate до super.
Вместо голого белого экрана при запуске.
2026-07-09 13:13:01 +00:00
b-dev-mobile 83e071673c talk-native: убран runBlocking со старта и пути звонка (аудит C4/п.7)
TalkVendorUserSync.ensureUser → suspend(IO). Прогрев на старте приложения —
в фоновом scope (раньше блокировал main в Application.onCreate, вкл. чтение
AuthStore/Keystore). Путь звонка: подготовка vendor-юзера в корутине (IO),
запуск CallActivity на main — убран ANR-риск на входящем звонке.
Поведение то же: при неготовности — тот же тост, фолбэк WebView сохранён.
2026-07-09 12:58:55 +00:00
b-dev-mobile d1d61d0cf8 ux: pull-to-refresh на всех списках (Этап 2 п.12)
Раньше свайп-обновление было только в Файлах. Добавлено (Material3
PullToRefreshBox): Почта (входящие/исходящие, force-обновление), Конференции
(список бесед — заменяет убранную кнопку ↻), Контакты (force + CTag — дёшево),
Задачи (список), Карточки (доски и открытая доска). Контакты: refresh()
получил параметр force (pull-to-refresh форсит sync мимо TTL; с CTag-проверкой
это 1 лёгкий PROPFIND).
2026-07-09 12:48:08 +00:00
b-dev-mobile 286ab9c8bf gitignore: dist/ (локальные архивы исходников для релизов) 2026-07-09 09:41:13 +00:00
b-dev-mobile b03cb09a6c changelog: календарь — кэш событий по CTag 2026-07-09 09:40:28 +00:00
b-dev-mobile fb57156ee9 calendar: кэш событий по CTag (Этап 2, DAV ч.2)
listCalendars теперь забирает и cs:getctag (тот же PROPFIND — ноль лишних
запросов; DavCalendar.ctag). Новый CalendarEventsCache — дисковый кэш
СЫРЫХ REPORT-ответов per (аккаунт, календарь, диапазон), ключ — CTag:
не менялся → события парсятся с диска (боевым парсером CalDavClient),
REPORT-запросы в сеть не идут. Изменился → сеть + обновление кэша.
Хранение сырого XML вместо сериализации моделей — без хрупкого
JSON-маппинга ~20 полей события; чистка записей старше 30 дней.
CalDavClient.queryEventsInRange разделён на queryEventsRawXml +
parseEventsXml (поведение прежнее). Эффект: переключение месяцев,
периодический рефреш и возврат в календарь при неизменных календарях —
1 PROPFIND вместо 1+N REPORT. +5 unit-тестов кэша (JVM, TemporaryFolder).
2026-07-09 09:39:07 +00:00
b-dev-mobile 4c403e5ec2 changelog: контакты — инкрементальная синхронизация по CTag 2026-07-09 06:48:19 +00:00
b-dev-mobile 00daa980a8 contacts: инкрементальная синхронизация по CTag (Этап 2, DAV)
CardDavClient.collectionSignature() — дешёвый PROPFIND depth:1 за CTag книг
(расширение CalendarServer, Nextcloud; фолбэк sync-token), без address-data.
ContactsRepository.syncContacts: перед полной закачкой всех vCard сверяет
подпись коллекций; не менялось → отдаём кэш (Room), не качаем. Ускоряет
pull-to-refresh (раньше — полный PROPFIND ~500 vCard каждый раз).
+4 unit-теста на parseCollectionSignature (regex-парсер, без Android XmlPull).
2026-07-09 06:44:49 +00:00
b-dev-mobile 2709dba03f ci: паркуем CI до Docker-режима (host-исполнение act_runner 0.2.13 сломано)
Диагноз: диспетчеризация Gitea работает, но host-исполнение act_runner
роняет шаги (No such file / MODULE_NOT_FOUND, задвоение hostexecutor/.cache).
Workflow → workflow_dispatch (не auto-fail на push), раннер остановлен.
Рабочий путь на будущее — Docker-образ с JDK+SDK + gradle-кэш (docs/CI.md).
Сборки/релизы пока вручную (v0.5.123 опубликован).
2026-07-09 04:56:43 +00:00
b-dev-mobile c53885b230 ci: обход бага host-режима act_runner — checkout обычным git (без JS-экшенов)
CI / verify (push) Failing after 1s
2026-07-09 04:50:55 +00:00
b-dev-mobile a696d45f2e ci: тест после чистой регистрации раннера
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 04:19:35 +00:00
b-dev-mobile 2ae03264ff ci: восстановлен runs-on [self-hosted, f7-android] (метка не была причиной)
CI / verify (push) Failing after 4s
CI / release (push) Has been skipped
2026-07-09 04:12:34 +00:00
b-dev-mobile 47c274e441 ci: тест — runs-on одна метка f7-android
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 04:10:19 +00:00
b-dev-mobile 34bb16c59d ci: тест триггера после апгрейда раннера 0.2.13
CI / verify (push) Failing after 4s
CI / release (push) Has been skipped
2026-07-09 04:06:54 +00:00
b-dev-mobile 0fcc575e76 ci: пустой коммит для проверки триггера Actions
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 03:55:57 +00:00
b-dev-mobile fff360fe39 build: версия 0.5.123 (131) — первый тегированный релиз (FLAG_SECURE + CI)
CI / verify (push) Has been skipped
CI / release (push) Failing after 6s
2026-07-09 03:38:26 +00:00
b-dev-mobile d047dea210 docs/CI: раннер развёрнут на инфре B (repo-scope, host-исполнение)
act_runner v0.2.11 + Node 20 + Docker + systemd, метки self-hosted/f7-android
на хосте, тёплый кэш /root/.gradle. Зарегистрирован в scope репозитория
(repo-токен; site-admin токен подхватывал чужую ячейку glb_adm — не годится).
2026-07-09 03:20:32 +00:00
b-dev-mobile cfa9fe2ffd security: FLAG_SECURE на экранах входа и PIN (аудит п.3)
Новый эффект F7SecureScreen(): пока экран на виду — окно с FLAG_SECURE
(нет в скриншотах/recents/записи экрана), снимается при уходе. Повешен на
LoginScreen (ввод пароля), AppLockUnlockScreen (разблокировка PIN) и
AppLockSetupDialog (создание PIN). Закрывает хвост Этапа 0 п.3.
2026-07-09 02:50:57 +00:00
b-dev-mobile e25dcbc403 build: версия 0.5.122 (130) — убраны чат-кнопка и обновление в Конференциях 2026-07-08 17:45:54 +00:00
b-dev-mobile 342e1e497d ui: убрана кнопка «чаты» в панели и кнопка обновления в Конференциях
- Нижняя панель: слева всегда «назад» (кнопки чатов больше нет; в
  Конференциях раньше был чат-слот) — единый layout во всех разделах.
- Экран Конференций: убран onRefresh у F7ModuleScreen → нет кнопки ↻
  сверху справа (список грузится при открытии и по push).
2026-07-08 17:45:34 +00:00
b-dev-mobile 6f82a3fc56 build: версия 0.5.121 (129) — без зелёной подложки в панели 2026-07-08 17:39:50 +00:00
b-dev-mobile fe330be510 bottombar: убрана зелёная подложка под активной кнопкой
Фон кнопок всегда нейтральный (0x99FFFFFF); убраны highlight-подложка,
BottomBarHighlightBrush и параметр highlighted слота. Зелёные варианты
иконок чатов/бургера (при открытии) — это иконка, остаются.
2026-07-08 17:39:14 +00:00
b-dev-mobile 58d56feb76 build: версия 0.5.120 (128) — крупные иконки разделов в панели 2026-07-08 17:33:21 +00:00
b-dev-mobile 58eed74b03 bottombar: иконки-разделы крупнее (46dp) — заполняют кружок
У серверных SVG (mail-/deck-/spreed-header-icon) внутренний «воздух»,
поэтому глиф оставался мелким. Отдельный размер BottomBarSectionIconSize=46dp
для Почты/Карточек/Конференций (стрелка/бургер — прежние 34dp).
2026-07-08 17:32:38 +00:00
b-dev-mobile 8186031676 build: версия 0.5.119 (127) — без чекбокса сертификата на входе 2026-07-08 16:53:03 +00:00
b-dev-mobile 9e5a16a605 login: убран чекбокс «Доверять сертификату» с экрана входа
Раньше он показывался только в debug (в release скрыт), теперь убран
совсем. trustAllCerts остаётся false по умолчанию (безопасно); trust-all
на уровне сети/WebView и так отключён в release (UnsafeSsl).
2026-07-08 16:48:02 +00:00
b-dev-mobile b56c9a5998 build: release на боевом ключе владельца, версия 0.5.118 (126)
Первая release-сборка подписана ключом владельца (не debug) — убирает
предупреждение Play Защиты. + иконки нижней панели 24→34dp.
2026-07-08 16:37:41 +00:00
b-dev-mobile d50eaaed3d bottombar: крупнее иконки в кружках (24dp → 34dp)
Иконки заполняли кружок только на ~44% — увеличены, чтобы «на весь кружок».
2026-07-08 16:32:48 +00:00
b-dev-mobile 3824ad86f2 build: версия 0.5.116(124) → 0.5.117(125) под ревью-сборку
Накоплено: нижняя навигац.панель + кнопки создания, меню (пол-экрана/
3 колонки/состав по дизайну), нативная панель профиля, нижний inset,
фикс ширины писем.
2026-07-08 15:39:25 +00:00
b-dev-mobile 89e32d2875 bottombar: навигационная панель по дизайну «Новое меню»
Панель стала единой навигационной: слева назад (в Конференциях — чаты),
далее ярлыки-разделы Почта · Карточки · Конференции (активный подсвечен),
справа выдвижное меню. Профиль/Уведомления/Настройки/Создать убраны из
панели (Профиль/Уведомления/Настройки — в выдвижном меню; бейдж
уведомлений перенесён на кнопку меню).

Создание раньше вызывалось ТОЛЬКО из панели → чтобы не потерять:
- новый общий компонент F7CreateButton (зелёный круг «+»);
- добавлен в шапку Файлов (→ меню создания) и Контактов (→ добавить контакт).
Задачи/Поддержка уже имели свои кнопки; Почта — «+» в шапке.

F7BottomBarConfig упрощён (единый layout), F7MobileBottomBar получил
слоты SectionMail/SectionCards/SectionConferences + activeTabKey.
2026-07-08 15:26:24 +00:00
b-dev-mobile a30af98a8d menu: состав пунктов точно по дизайну (+Уведомления/Настройки)
По дизайну выдвижного меню: Файлы/Календарь/Контакты/Карточки/Заметки +
Bitrix/1C/Личный кабинет + Уведомления/Настройки. Убраны Почта/Конференции/
Задачи/Поддержка (остаются в нижней панели).
- F7AppMenuItem: опц. localIcon (ImageVector); нативные пункты рисуются в
  круглом бейдже с зелёной обводкой (стиль glass) вместо серверной SVG;
- Уведомления → NotificationsSheet, Настройки → настройки активного таба,
  обработка по label в AppScaffold.onItemClick.
2026-07-08 13:18:45 +00:00
b-dev-mobile fda34a5b69 menu: сетка 3 колонки + подпись во всю ширину ячейки (по дизайну)
Дизайн выдвижного меню — 3 колонки (было 4), иконки-бейджи *-glass.svg
уже совпадают со стилем. Ячейка стала кликабельной целиком (clip+ripple),
подпись использует ширину ячейки (Личный кабинет/Уведомления не дробятся).
2026-07-08 12:56:17 +00:00
b-dev-mobile 3d9f3819d9 profile: нативная панель профиля вместо веб-ЛК forbion
«Личный кабинет» открывал внешнюю веб-ссылку → десктопный дропдаун
forbion, криво на мобильном. Теперь пункт открывает нативную панель
(ProfileSheet, bottom-card F7FloatingPanel):
- шапка: аватар-инициал + отображаемое имя (тянется из OCS cloud/user)
  + «Открыть профиль» (deep-link /u/<id>) + кнопка QR-скана браузера;
- пункты: Установить статус / Личные настройки / Учётные записи
  (deep-link в веб-настройки), О программе и что нового (нативный
  About-диалог с версией), Выйти (нативный logout, красный).
Material-иконки (extended уже в app). Точки входа: пункт меню
«Личный кабинет» + кнопка профиля в нижней панели. Компилируется.
2026-07-08 12:45:35 +00:00
b-dev-mobile 04f67e071b ui: контент не залезает под системную навигацию (нижний inset)
Edge-to-edge (targetSdk 36): контент был full-bleed без нижнего inset →
уезжал под системные 3 кнопки/жест-панель (плавающая нижняя панель свой
navigationBarsPadding имела, а контент — нет). Добавил f7SafeBottomInsets
к контенту в F7AppScaffold (верх уже был через f7SafeTopInsets).
2026-07-08 12:25:43 +00:00
b-dev-mobile b83d3dc872 mail: письма вписываются по ширине экрана (WebView useWideViewPort)
HTML-письма (широкие таблицы Битрикс/рассылок) уезжали за правый край:
WebView без useWideViewPort игнорировал viewport width=device-width и
раскладывал по ~980px. Включил useWideViewPort+loadWithOverviewMode
(вписывает/масштабирует в обзор) + пинч-зум. READER_CSS уже имел
overflow-x:hidden/table-layout:fixed — теперь работает как задумано.
2026-07-08 12:25:43 +00:00
b-dev-mobile 530663754a menu: лончер приложений → bottom-sheet на пол-экрана (v0.5.116/124)
F7AppMenuSheet был fillMaxSize с непрозрачным фоном (закрывал весь экран).
Теперь выезжает снизу и занимает ~пол-экрана: якорь BottomCenter,
fillMaxHeight(0.6f), скруглённый верх 20dp + тень, ручка-хендл, лёгкий
скрим над листом (тап закрывает), зазор под нижнюю панель, сетка иконок
скроллится. Экран под меню виден сверху. Бамп версии 0.5.115→0.5.116.
2026-07-08 12:14:36 +00:00
b-dev-mobile 8cee4d61bf build: бамп версии 0.5.114(122) → 0.5.115(123) под дизайн-веху
Стиль проекта: патч в третьем числе +1, versionCode +1. Дизайн-ревью-
сборки теперь несут отдельную версию, а не WIP-линию 0.5.114.
2026-07-08 10:51:50 +00:00
b-dev-mobile 2220fe4a89 changelog: рескин панели Файлов + месячной сетки Календаря 2026-07-08 10:18:02 +00:00
b-dev-mobile a727e2dbd1 calendar: рескин месячной сетки под макет
По «Календарь · Месяц»: дни недели — серые пилюли; сетка безрамочная с
гридлайнами (0.5dp) вместо скруглённых боксов; номер дня сверху-слева;
сегодня — в зелёном круге (Primary, белый текст); событие — зелёная
пилюля PrimaryLight (до 2 + «+N»), было одно превью/точка. Компактная
модель (месяц + список дня снизу) сохранена. Компилируется.
2026-07-08 10:15:41 +00:00
b-dev-mobile 335468b80f files: рескин панели свойств/доступа под макет (табы-пилюли, шапка, звезда)
Правая панель (FilesDetailsSheet) по макету «Правая панель свойства/доступ»:
- табы из текст+подчёркивание → две иконки-пилюли (Обсуждение 💬 / Доступ 👥),
  активная — зелёная заливка PrimaryLight;
- шапка: крестик в правом верхнем углу, чип владельца (аватар-инициал+логин),
  дата/размер справа, звезда-избранное (Icons Star/StarBorder, wired на
  vm.toggleFavorite) вместо строки «Владелец … • …»;
- кнопка отправки комментария → зелёный круг-градиент с белой стрелкой (было «→»).
Иконки — Material (extended уже в модуле). Компилируется.
2026-07-08 10:11:57 +00:00
b-dev-mobile b15a97d30e changelog: подложка чата + теги/экран сообщения почты 2026-07-08 09:39:07 +00:00
b-dev-mobile 79e6b4ec83 mail: точные цвета тегов из макета (Работа/Позже/Личное)
Сняты пипеткой с «Почта · Список сообщений» (Figma):
Работа #DAEDD6/#28A413, Позже #F1EEE0/#C1B464, Личное #EFE4FB/#B574F7.
Раньше были приблизительные зелёный/коричневый/фиолетовый.
2026-07-08 09:36:52 +00:00
b-dev-mobile 8c3d422c85 talk: подложка чата из дизайна (Подложка2), локальный webp
TalkChatBackground грузил chat-pattern.svg с сервера поверх плоского фона.
Заменил на бандл-ресурс R.drawable.chat_background (светлый градиент +
паттерн иконок из Figma, Подложка2) — офлайн и точно из макета.
PNG 481КБ → WebP 49КБ (q82). ContentScale.Crop, full-bleed.
2026-07-08 09:33:31 +00:00
b-dev-mobile 483e4107d7 Дизайн-палитра из Figma в F7Colors + фикс Error-красного
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
- Точные Base-токены из «Брендбука» владельца: Black #151515, Grey1/2/3
  #808080/#F5F5F5/#E6E6E6, Green #70B62B (+30/10%), Yellow #F6E120,
  Purple #9747FF, Red #FF7A66 (+10%).
- Семантика (TextPrimary/Secondary/Border/SurfaceMuted/Primary) на Base — совпала.
- ИСПРАВЛЕНО: Error #D74642 → #FF7A66 (дизайн-красный) — app-wide на кнопках
  удаления/ошибках. Yellow/Purple под теги.

designsystem компилируется. Шаг к закрытию долга «77 захардкоженных Color()».
2026-07-08 09:05:26 +00:00
b-dev-mobile 696ed8d802 Дизайн Почты: теги письма в карточке списка (макет «Список сообщений»)
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
Строка письма уже карточная (аватар+имя+дата+тема) — близка к макету. Добавлены
цветные теги-пилюли (Работа/Позже/Личное) под темой (message.tags, MailTagChip).
Верхний бар (поиск+фильтр+compose) уже был. feature:mail компилируется.
2026-07-08 08:37:50 +00:00
b-dev-mobile f280e19418 Дизайн Файлов (чанк 3): рескин каркаса главного экрана под макет
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
- FilesBrowserChrome.kt: поиск-пилюля + переключатель список⇄сетка, хлебные
  крошки (навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен)
  вместо десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки),
  пустое состояние (иконка+заголовок+подзаголовок).
- Строки списка без разделителей (по макету). Токены F7Spacing/F7Radius.
- material-icons-extended → feature:files (иконки chrome; точные из дизайна — потом).

feature:files компилируется. Логика (загрузка/выбор/открытие) не менялась.
2026-07-08 08:12:52 +00:00
b-dev-mobile 078fffd405 Дизайн Файлов (чанк 2): локальные иконки типов из дизайна (офлайн-фикс)
CI / verify (push) Failing after 4s
CI / release (push) Has been skipped
- 13 SVG-иконок типов из Figma-набора (PDF/Word/Spreadsheet/Presentation/Image/
  Audio/Video/Archive/Code/Text/Certificate/Eml/file) → assets/filetypes/.
- FileIcons переключён на локальные (file:///android_asset, грузятся coil-svg):
  офлайн + точно из дизайна. Раньше тянулись с сервера (аудит: офлайн ломался).
- Папка пока с сервера (иконки папки в наборе дизайна нет — TODO из Figma).

feature:files компилируется.
2026-07-08 07:59:37 +00:00
b-dev-mobile 04227fa5c8 Дизайн Файлов (чанк 1): строка списка по макету (Figma F-18/F-21/F-22)
CI / verify (push) Failing after 1m9s
CI / release (push) Has been skipped
- Имя + расширение инлайн (расширение серым TextSecondary), одна строка —
  вместо имени в 2 строки + мета (размер·дата). По мобильному макету.
- Выбранная строка — светло-зелёный фон (F7Colors.PrimaryLight), скругление.
- Иконка типа 28→24dp. На токенах F7Spacing/F7Radius.
- Строка на существующей загрузке иконок с сервера; локальные ico + офлайн — следом.

feature:files компилируется. Визуальная сверка — на устройстве (side-by-side).
2026-07-08 07:33:27 +00:00
b-dev-mobile 312537cd8a Трек дизайна: токены-фундамент (F7Spacing/F7Radius) + карта Figma
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
- Снятие токенов из Figma роли design (ocVUCYCrfFUoqYYvlcLPBF): цвета сверены —
  совпадают с F7Colors; типографика Raleway 14/18/20/24 (база в F7Typography есть).
- F7Spacing (4dp-сетка) + F7Radius — закрывают аудитный пробел «нет spacing-токенов».
- docs/DESIGN-SOURCE.md: карта страниц/секций Figma, доступ по API, процесс
  попиксельной вёрстки, заметка про rate-limit (точечные node-запросы).

Источник — Figma (боевой forbion не трогаю). Координация mail/044. designsystem компилируется.
2026-07-08 00:56:43 +00:00
b-dev-mobile fd96ca89e6 Этап 1: version catalog (libs.versions.toml) — устранён дрейф версий
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
- gradle/libs.versions.toml: единый источник версий (Gradle 9.3 авто-подхват).
- 15 module build.gradle мигрированы на libs.* (скриптом, регекс по group:name).
- Устранён дрейф: coroutines 1.8.1/1.10.1→1.10.1, coil 2.6.0/2.7.0→2.7.0,
  lifecycle 2.8.1/2.8.7→2.8.7, activity-compose 1.9.0/1.10.1→1.10.1,
  core-ktx 1.13.1/1.15.0→1.15.0. Firebase в core/push → bom (был explicit 24.0.1).
- Compose BOM в каталоге вместо дубля в каждом модуле.

Проверено: assembleRelease BUILD SUCCESSFUL, APK 53 МБ.
2026-07-07 23:53:30 +00:00
b-dev-mobile f006a69b5c Этап 1: detekt в CI verify-джобе + CHANGELOG
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
2026-07-07 23:37:11 +00:00
b-dev-mobile ac438269c5 Этап 1: detekt (статанализ + ktlint-форматирование) с baseline
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
- detekt 1.23.8 применён ко всем НАШИМ модулям (vendor-форк исключён),
  +formatting-ruleset (обёртка ktlint). Совместим с Kotlin 2.3 (проверено).
- config/detekt/detekt.yml — настройки под наш код (LongMethod 120, MaxLine 140,
  MagicNumber off — dp/цвета отдельно, FunctionNaming off — @Composable Заглавные).
- detekt-baseline.xml по модулям: ~403 текущих замечаний зафиксированы →
  detekt зелёный, дальше ловит только НОВЫЕ. detekt в CI (ci.yml verify).

Проверено: detektBaseline + detekt BUILD SUCCESSFUL.
2026-07-07 23:36:40 +00:00
b-dev-mobile ab8fa70e51 Этап 1: единый OkHttpClient ч.2 — 401 централизован через interceptor
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
- UnauthorizedInterceptor (401→UnauthorizedException) в NetworkFactory,
  флаг throwOnUnauthorized (по умолчанию true).
- Удалены 75 ручных проверок if(code==401) throw в 16 репозиториях.
- AuthVerifier (login-верификация) исключён: throwOnUnauthorized=false —
  401 = 'неверный пароль' со своим сообщением, не session-expired.
- Побочно: 401 теперь ловится единообразно ВЕЗДЕ (включая пути, что раньше
  забывали проверку).
- +3 MockWebServer-теста (401 бросает; 200/403 проходят) — всего 18 тестов.

Проверено: 18 тестов зелёные + assembleRelease BUILD SUCCESSFUL, APK 53 МБ.
2026-07-07 23:29:49 +00:00
b-dev-mobile c45ebd8d49 Этап 1: единый OkHttpClient (переиспользование пула/кэша) — ч.1
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
Раньше NetworkFactory создавал новый OkHttpClient на КАЖДЫЙ запрос (~32 места):
TLS-handshake + новый пул/диспатчер на запрос + утечка ExecutorService.
Теперь: один базовый клиент (общий ConnectionPool/Dispatcher + диск-кэш 20МБ),
авторизованные варианты через base.newBuilder() (шарят пул/кэш), кэшируются по
кредам+таймаутам. Публичный API НЕ менялся — 32 вызова не тронуты.
Init диск-кэша в F7MobileApp.onCreate.

+3 unit-теста (переиспользование клиента, общий пул). 401→interceptor НЕ делал
(меняет семантику исключений — отдельный шаг).

Проверено: 15 тестов зелёные + assembleRelease BUILD SUCCESSFUL, APK 53 МБ.
2026-07-07 23:14:20 +00:00
b-dev-mobile d9cacb8c9c build: debug — все ABI (x86 для BlueStacks/эмуляторов), release — ARM-only
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
ABI задаются побилдтипово: debug включает x86/x86_64 → нативно на эмуляторах
(BlueStacks) и на ARM-девайсах для тестирования; release остаётся arm64+armeabi-v7a
(компактный прод). Проверено: debug-APK содержит все 4 ABI.
2026-07-07 22:50:49 +00:00
b-dev-mobile f0708a30ac Этап 1: CI (Gitea Actions) + GPL corresponding source (п.4c)
CI / verify (push) Has been cancelled
CI / release (push) Has been cancelled
- .gitea/workflows/ci.yml: push/PR → testDebugUnitTest+lintRelease+assembleDebug;
  тег v* → подписанный assembleRelease + архив исходников в Gitea Release.
  Gitea 1.26.1 поддерживает Actions. Self-hosted раннер (метка f7-android),
  т.к. проект тяжёлый (WebRTC) и нужен тёплый Gradle-кэш.
- scripts/package-source.sh: GPL corresponding source (§6) — архив всего дерева
  вкл. vendor talk-android (GPL), LICENSE, NOTICE. Проверен: 6.7 МБ, 2353 файла.
  Закрывает п.4c, работает и вручную до появления раннера.
- docs/CI.md: пайплайн, регистрация раннера, secrets.

НЕ активно до регистрации раннера на инфре B (нужен Gitea-админ для токена).
2026-07-07 22:32:38 +00:00
b-dev-mobile b90107a7fc Этап 1: баги данных (test-first) — ICS-парсер, QR-пароль, курсор почты
Заведена тестовая инфраструктура (core:network junit) + 12 unit-тестов. Починено:
- ICS parseIcsInstant: суффикс Z → UTC (был локальным!), TZID учитывается, floating→локаль
- parseProps сохраняет параметры: несколько ATTENDEE не схлопываются, CN/PARTSTAT/ROLE
  читаются (была порча участников при редактировании события → PUT на сервер)
- unescape single-pass (последовательные replace ломались на экранированном бэкслеше)
- CalDavClient.parseIcsInstant делегирует в CalendarIcs — устранён рассинхрон календарь↔задачи
- LoginFlowClient: маркер-парсер вместо split('&') — QR-пароль с &/: не теряется
- MailRepository/ViewModel/Models/Cache/Screen: курсор пагинации Int→Long (обрезка ~2038)

Проверено: 12 тестов зелёные (CalendarIcsTest 8 + LoginFlowClientTest 4) + assembleRelease OK.
2026-07-07 22:21:37 +00:00
b-dev-mobile cef526e778 Этап 0 п.5 (gradle-гигиена): parallel=true + удалены мёртвые модули
- org.gradle.parallel=true (быстрее сборка 20 модулей)
- удалены core:data, core:ui (пустые заготовки) и feature:widgets (не в
  settings.gradle, не компилировался) + чистка settings.gradle
- nonTransitiveRClass=true и убирание enableJetifier ОТКАЧЕНЫ: vendor-форк
  talk-android их не переживает (R транзитивно; legacy android.support.* в
  зависимостях типа FlexibleAdapter). Причины — в комментах gradle.properties.

Проверено сборкой: assembleRelease BUILD SUCCESSFUL. Этап 0 закрыт.
2026-07-07 21:47:49 +00:00
b-dev-mobile 5eb6efb36b Приватные уведомления на локскрине + откат биопривязки (требование владельца: звонки/push при блокировке)
Владелец: входящие звонки и push должны работать при заблокированном телефоне.
- Биопривязка app password ОТКЛОНЕНА (вариант А): блокировала приём звонка без
  разблокировки. Откачена из main (сохранена на ветке wip/biometric-binding).
  Пароль остаётся под Keystore AES-256-GCM (п.3) — работает в фоне.
- Приватность локскрина: сообщения и звонки → VISIBILITY_PRIVATE + generic
  public-версия ('Новое сообщение'/'Входящий звонок'); канал lockscreenVisibility=PRIVATE.
  Полный контент — после разблокировки. Push приходит plaintext, для показа/звонка
  пароль не нужен → звонки звонят и пуши приходят при блокировке.

Проверено сборкой: assembleRelease BUILD SUCCESSFUL, APK 53 МБ.
2026-07-07 21:22:24 +00:00
248 changed files with 16068 additions and 2403 deletions
+72
View File
@@ -0,0 +1,72 @@
name: CI
# ⏸️ ПАРКОВКА (2026-07-09): CI отложен. Раннер на инфре B развёрнут, но
# host-исполнение act_runner 0.2.13 в нашем окружении сломано (криво резолвит
# пути к шагам/JS-экшенам: MODULE_NOT_FOUND / "No such file", задвоение
# hostexecutor/.cache/act). Рабочий путь — Docker-исполнение с кастомным образом
# (JDK 21 + Android SDK 36) и volume для ~/.gradle. До этого — сборки/релизы вручную
# на билд-машине (см. docs/CI.md).
#
# Триггер временно ручной (workflow_dispatch), чтобы push'и не создавали падающие
# прогоны. Когда настроим Docker-раннер — вернуть блок on: push/pull_request ниже.
#
# on:
# push:
# branches: [main]
# tags: ['v*']
# pull_request:
on:
workflow_dispatch:
env:
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
ANDROID_HOME: /opt/android-sdk
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
jobs:
verify:
# detekt + unit-тесты + Android Lint + debug-сборка на каждый push в main и на PR
runs-on: [self-hosted, f7-android]
if: github.ref_type != 'tag'
steps:
- uses: actions/checkout@v4
- name: Статанализ (detekt + ktlint-форматирование)
run: ./gradlew detekt --no-daemon
- name: Unit-тесты
run: ./gradlew testDebugUnitTest --no-daemon
- name: Android Lint (release)
run: ./gradlew :app:lintRelease --no-daemon
- name: Debug-сборка (артефакт для QA)
run: ./gradlew :app:assembleDebug --no-daemon
- name: Публикация debug-APK
uses: actions/upload-artifact@v3
with:
name: f7cloud-mobile-debug
path: app/build/outputs/apk/debug/*.apk
release:
# по тегу v*: подписанный release + архив исходников (GPL corresponding source) в Gitea Release
runs-on: [self-hosted, f7-android]
if: github.ref_type == 'tag'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Развернуть keystore из secret
run: |
echo "${{ secrets.F7_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/release.jks"
- name: Release-сборка (подписанная)
env:
F7_KEYSTORE_FILE: ${{ runner.temp }}/release.jks
F7_KEYSTORE_PASSWORD: ${{ secrets.F7_KEYSTORE_PASSWORD }}
F7_KEY_ALIAS: ${{ secrets.F7_KEY_ALIAS }}
F7_KEY_PASSWORD: ${{ secrets.F7_KEY_PASSWORD }}
run: ./gradlew :app:assembleRelease --no-daemon
- name: Архив исходников (GPL corresponding source)
run: bash scripts/package-source.sh "$RUNNER_TEMP/dist"
- name: Публикация Gitea Release (APK + исходники)
uses: akkuman/gitea-release-action@v1
with:
files: |
app/build/outputs/apk/release/*.apk
${{ runner.temp }}/dist/*-source.tar.gz
+1
View File
@@ -24,3 +24,4 @@
*.log
*.tmp
.cxx/
dist/
+71
View File
@@ -2,6 +2,77 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — правка/удаление в UI (P1, contacts.diff, шаг 2/2).** Room: `ContactEntity` +`href`/`etag`/`raw`, миграция `MIGRATION_4_5` (v4→5, аддитивные ALTER ADD COLUMN), `ContactsDao.deleteByUid`. `ContactsRepository`: маппинг href/etag/raw, `updateContact` (round-trip vCard через `VCardEditor` + PUT If-Match) и `deleteContact` (DELETE If-Match + вычистка Room). `ContactItem` +3 поля. `ContactsViewModel`: `openEditSheet`/`closeEditSheet`/`saveEdit`/`deleteContact` + состояние (savingEdit/editError/deletingContact). UI: в карточке кнопки «Править» (форма имя/организация/должность/email/телефон/адрес/сайт/ДР) и «Удалить» (с подтверждением) — только для синхронизированных (есть href/raw). Блок действий вынесен в `ContactDetailActions`. Гейт `:app:compileReleaseKotlin` зелёный; `:feature:contacts:detekt`+compile зелёные (baseline `ContactDetailContent`/`parseContacts` пере-привязан под сдвинутые сигнатуры). NB: P1-edit нормализует TYPE у EMAIL/TEL к INTERNET/CELL (сохранение/правка типов — P2). На устройстве/сервере не гонялось (компил-гейт + юниты ядра); live-проверка на `bmob-*` (mail/085) — следующим шагом. | контракты: core `ContactEntity`+3 поля (Room v4→5 миграция), `CardDavClient` уже расширен в bc1a497; feature-слой изолирован | средний: миграция Room (общий core:database) — проверить апгрейд БД на устройстве; правка/удаление на живом CardDAV не гонялись
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (P1, contacts.diff, шаг 1/2).** `CardDavClient`: `updateContact` (PUT If-Match по ETag, round-trip неизвестных свойств vCard — PHOTO/UID/X-* сохраняются) и `deleteContact` (DELETE If-Match, 404=уже удалён, 412=конфликт ETag). `DavContact` +`href`/`etag`/`raw` (заполняются чтением книги; сигнатуры list/create НЕ менялись). `parseContacts` теперь ловит href/getetag на каждый `<response>`; общий `davContactFromVCard`. Чистый движок `VCardEditor.applyEdits` (замена управляемых FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE, фолдинг 75, CRLF, экранирование) + модели `VCardFields`/`VCardTypedValue`. **10 юнит-тестов** (`VCardEditorTest`) зелёные: round-trip неизвестных полей, FN с запятыми, PHOTO base64, folded ADR, CRLF-фолдинг. Гейт `:app:compileReleaseKotlin` зелёный. Шаг 2/2 (Room href/etag/raw + миграция, форма правки в карточке, delete в UI) — следующим коммитом. | контракты: core `CardDavClient` +2 метода, `DavContact` +3 опц. поля (обратно совместимо, list/create не тронуты); одобрено лидом mail/072 | низкий: только добавление методов + поля с дефолтами, синк не задет; на устройстве/сервере ещё не гонялось (юниты + компил-гейт)
- 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).** Пункт «Переслать…» открывает лист выбора комнаты (`ForwardTargetSheet` — список бесед кроме текущей) → `TalkViewModel.forwardToRoom` шлёт текст в выбранную (OCS `/chat` v1). Диалог удаления вынесен в `ConfirmDeleteMessageDialog` (держит новый `MessageActionOverlays` <120 строк). Завершает историю пересылки (в «Личные заметки» + в произвольную комнату). Baseline пере-привязан (сигнатура ChatContent от нового параметра). | контракты не менялись (OCS /chat v1) | низкий: UI-действие в модуле, на устройстве не гонялось
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Ответить лично» + рефактор (P1).** Пункт «Ответить лично» в меню сообщения (для чужих сообщений): `TalkViewModel.replyPrivately` открывает/создаёт 1:1 с автором через существующий `createOneToOneRoom` (OCS spreed v4, сервер идемпотентен) и `openRoom`. Заодно рефактор: блок оверлеев сообщения (лист действий + диалоги правки/удаления) вынесен из `ChatContent` в отдельный `MessageActionOverlays``ChatContent` LongMethod 267→174, cyclomatic 29→<25 (перестал плодить baseline-churn на каждый пункт меню). Baseline пере-привязан (сигнатура ChatContent сдвинулась от нового параметра); новый код чист (компиляция+detekt зелёные). | контракты не менялись (OCS spreed v4/v1) | низкий: изолированное 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-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.1 — контакты): инкрементальная синхронизация по CTag. `CardDavClient.collectionSignature()` — дешёвый PROPFIND depth:1 за CTag адресных книг (расширение CalendarServer, поддерживается Nextcloud; фолбэк на sync-token), без address-data. `ContactsRepository.syncContacts` перед полной закачкой всех vCard сверяет подпись коллекций: не менялось → отдаём кэш (Room), сеть не грузим. Ускоряет pull-to-refresh (было — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста (regex-парсер подписи, без Android XmlPull). Дальше по Этапу 2: кэш событий календаря + CTag (сейчас экран календаря тянет события живьём по диапазону — под него сначала нужен кэш) | контракты не менялись | низкий: только оптимизация, поведение то же при изменениях
- 2026-07-09 | v0.5.123 (131) | Первый тегированный релиз в Gitea (тег `v0.5.123` + Release с APK и архивом исходников GPL). Включает хвосты этапов 0/1: FLAG_SECURE на экранах входа/PIN (аудит п.3, коммит cfa9fe2) + активированный CI-раннер на инфре B (docs/CI). Функциональных изменений в приложении относительно v0.5.122 нет, кроме FLAG_SECURE | контракты не менялись | низкий
- 2026-07-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI
- 2026-07-08 | v0.5.121 (129) | Нижняя панель: убрана зелёная подложка под активной кнопкой (фон всегда нейтральный). Активный раздел больше не выделяется фоном (зелёных вариантов иконок разделов нет). Зелёные иконки чатов/бургера при открытии остаются | контракты не менялись | низкий: только фон кнопок
- 2026-07-08 | v0.5.120 (128) | Нижняя панель: иконки-разделы (Почта/Карточки/Конференции) крупнее — 46dp (у серверных SVG внутренний «воздух», глиф оставался мелким). Стрелка/бургер — прежние 34dp | контракты не менялись | низкий: только размер иконок
- 2026-07-08 | v0.5.119 (127) | Убран чекбокс «Доверять сертификату» с экрана входа (раньше только в debug, теперь совсем). trustAllCerts=false по умолчанию; trust-all на сети/WebView в release и так отключён | контракты не менялись | низкий: только UI входа
- 2026-07-08 | v0.5.118 (126) | Первая RELEASE-сборка на боевом ключе владельца (release-keystore.jks, alias f7cloud, SHA-256 5F:E5:F0:6C…). Раньше отдавались debug-сборки (debug-ключ сервера) → Play Защита ругалась «незнакомый разработчик». Теперь release, подписан ключом владельца → предупреждения нет, обновления ставятся поверх. signing.env читает креды из /root/.f7cloud-keys/key.properties (в git НЕ коммитится). + иконки нижней панели крупнее (24dp→34dp, заполняют кружок) | контракты не менялись | средний: смена контура подписи на боевой ключ; debug-сборку v117 надо удалить перед установкой (иная подпись)
- 2026-07-08 | v0.5.117 (125) | Нижняя панель → навигационная по дизайну «Новое меню» (other/Новое меню.png): слева назад (в Конференциях — чаты), ярлыки-разделы Почта · Карточки · Конференции (активный подсвечен), справа выдвижное меню. Профиль/Уведомления/Настройки/Создать убраны из панели (первые три — в выдвижном меню, бейдж уведомлений на кнопке меню). `F7BottomBarConfig` упрощён, `F7MobileBottomBar` +слоты SectionMail/Cards/Conferences +activeTabKey. Создание Файлов/Контактов вызывалось ТОЛЬКО из панели → новый `F7CreateButton` (зелёный «+») в шапках Файлов и Контактов (Задачи/Поддержка/Почта свои имели) | контракты не менялись | средний: изменена навигация нижней панели; проверить переходы разделов и создание в Файлах/Контактах
- 2026-07-08 | v0.5.117 (125) | Дизайн выдвижного меню: сетка 3 колонки (было 4), ячейка кликабельна целиком, подпись во всю ширину. Состав пунктов точно по дизайну — Файлы/Календарь/Контакты/Карточки/Заметки + Bitrix/1C/Личный кабинет + Уведомления/Настройки (убраны Почта/Конференции/Задачи/Поддержка — они в нижней панели). Уведомления→шит, Настройки→настройки активного таба (нативные пункты рисуются локальной иконкой в круглом бейдже) | контракты не менялись | низкий: состав/вид меню
- 2026-07-08 | v0.5.117 (125) | Нативная панель профиля вместо веб-ЛК forbion: «Личный кабинет» открывал внешнюю ссылку (десктопный дропдаун forbion, криво на моб.) → теперь нативный bottom-card (ProfileSheet): аватар-инициал + имя (OCS cloud/user) + Открыть профиль/QR + Установить статус/Личные настройки/Учётные записи (deep-link веб) + О программе (нативный About) + Выйти. Точки входа: пункт меню + кнопка профиля | контракты не менялись; +GET ocs/cloud/user (имя) | низкий: только панель профиля
- 2026-07-08 | v0.5.117 (125) | Fix insets (edge-to-edge, targetSdk 36): контент залезал под системную навигацию снизу — добавлен нижний inset (`f7SafeBottomInsets`) к контенту в F7AppScaffold (верх уже был). | контракты не менялись | низкий: отступы контента
- 2026-07-08 | v0.5.117 (125) | Fix отображения писем: HTML-письма (широкие таблицы Битрикс/рассылок) уезжали за правый край — WebView без useWideViewPort игнорировал viewport width=device-width. Включены useWideViewPort+loadWithOverviewMode+пинч-зум | контракты не менялись | низкий: только рендер письма
- 2026-07-08 | v0.5.116 (124) | Меню-лончер (сетка приложений) → bottom-sheet на пол-экрана (требование владельца «выходить снизу вверх только на пол экрана»). `F7AppMenuSheet` был `fillMaxSize` с непрозрачным фоном → закрывал весь экран. Теперь: якорь снизу (`Alignment.BottomCenter`), высота `fillMaxHeight(0.6f)`, скруглённый верх (20dp) + тень, ручка-хендл, лёгкий скрим (0.18) над листом с тапом-для-закрытия, зазор `BottomBarReserve` под нижнюю панель, сетка иконок скроллится (`weight(1f)`). Экран под меню теперь виден сверху. Компилируется | контракты не менялись | низкий: только оверлей меню
- 2026-07-08 | v0.5.115 (123) | Бамп версии под дизайн-веху (0.5.114→0.5.115, code 122→123, стиль проекта: патч +1 / code +1). До этого Этапы 0/1 + весь дизайн ошибочно шли одной WIP-линией 0.5.114 (122) — теперь ревью-сборки дизайна помечены отдельной версией. Дальше — бампить на каждую заметную веху/ревью-сборку | версионирование | нет: только versionCode/Name
- 2026-07-08 | v0.5.114 (122) | Дизайн Календаря (месячная сетка): по «Календарь · Месяц» — дни недели вынесены в серые пилюли; сетка стала безрамочной с тонкими гридлайнами (0.5dp Grey3) вместо скруглённых боксов; номер дня сверху-слева; сегодня — белое число в зелёном круге; событие — зелёная пилюля PrimaryLight (до 2 шт + «+N») вместо одного превью/точки. Компактная мобильная модель (месяц сверху + список событий выбранного дня снизу) сохранена — не десктопная таблица. `CalendarMonthGrid`/`CalendarDayCell`, убран неиспользуемый параметр daysWithEvents. Проверено сборкой | контракты не менялись | низкий: только вид месяца
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (правая панель свойств/доступа): `FilesDetailsSheet` по «Правая панель свойства/доступ» — табы из текст+подчёркивание → две иконки-пилюли (Обсуждение 💬 / Доступ 👥, активная — зелёная заливка PrimaryLight); шапка: крестик в правом верхнем углу, чип владельца (аватар-инициал + логин), дата/размер справа, звезда-избранное (wired на vm.toggleFavorite) вместо строки «Владелец…»; кнопка отправки комментария → зелёный круг-градиент с белой стрелкой. Иконки — Material (extended уже в модуле). Проверено сборкой | контракты не менялись | низкий: визуал панели, логика (шэры/события/избранное) та же
- 2026-07-08 | v0.5.114 (122) | Дизайн Конференций (подложка чата): `TalkChatBackground` грузил chat-pattern.svg с сервера поверх плоского фона → заменён на бандл-ресурс `R.drawable.chat_background` (Подложка2 из дизайна — светлый градиент + водяной паттерн иконок, владелец выгрузил). PNG 481КБ → WebP 49КБ (q82, ×10), `feature/talk/res/drawable-nodpi`. ContentScale.Crop, full-bleed. Офлайн + точно из макета. Проверено сборкой | контракты не менялись | низкий: только фон экрана чата
- 2026-07-08 | v0.5.114 (122) | Дизайн Почты (теги + сверка экрана сообщения): цвета тегов-пилюль сняты пипеткой с макета «Почта · Список сообщений» — Работа #DAEDD6/#28A413, Позже #F1EEE0/#C1B464, Личное #EFE4FB/#B574F7 (fallback tagChipColors, когда сервер не отдаёт colorHex; раньше приблизительные). Экран СООБЩЕНИЯ сверен с «Почта · Окно сообщения» — уже совпадает (тема-заголовок, sender-card, тело, плавающая пилюля «↩ Ответить» MailReplyFab на градиенте Primary), правок не требует. Проверено сборкой | контракты не менялись | низкий: только цвета тегов
- 2026-07-08 | v0.5.114 (122) | Дизайн-палитра из Figma «Брендбук» (владелец) в F7Colors: точные Base-токены (Black #151515, Grey1 #808080, Grey2 #F5F5F5, Grey3 #E6E6E6, Green #70B62B +30%/10%, Yellow #F6E120, Purple #9747FF, Red #FF7A66 +10%). Семантические токены (TextPrimary/Secondary/Border/SurfaceMuted/Primary) переведены на Base — основа сверена, совпала. **ИСПРАВЛЕНО расхождение: Error #D74642 → #FF7A66 (дизайн-красный)** — влияет на кнопки удаления/ошибки во ВСЁМ приложении. Yellow/Purple — под теги. Пропагируется на все экраны; шаг к закрытию аудитного долга «77 захардкоженных Color(0x…)» | дизайн-система | средний: Error-красный меняется app-wide (по дизайну); проверить экраны удаления/ошибок
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 3 — каркас списка): рескин главного экрана под мобильный макет (Figma files/*.png на инфре B). Новый FilesBrowserChrome.kt: поиск-пилюля + кнопка-переключатель список⇄сетка, хлебные крошки (🏠 › путь, навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) ВМЕСТО десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). Строки без разделителей (по макету). На токенах F7Spacing/F7Radius. material-icons-extended → feature:files (иконки chrome, потом точные из дизайна). Компилируется | контракты не менялись | средний: заметный UI-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 2): локальные иконки типов файлов. 13 SVG из дизайна (PDF/Word/Spreadsheet/Presentation/Image/Audio/Video/Archive/Code/Text/Certificate/Eml/file) в assets/filetypes/, FileIcons переключён на них (file:///android_asset, coil-svg). Раньше иконки типов тянулись с сервера → офлайн ломался (аудит) + не гарантированно совпадали с дизайном. Папка пока с сервера (иконки папки в наборе нет — TODO из Figma). Дизайн-макеты (109 PNG) — на инфре B /home/foradmin/figma/, в git не бандлю (вес) | контракты не менялись | низкий: файловые иконки теперь офлайн и точно из дизайна
- 2026-07-08 | v0.5.114 (122) | Трек дизайна (старт): снятие дизайн-токенов из Figma роли design (файл ocVUCYCrfFUoqYYvlcLPBF, доступ по API-токену вне git). Цвета сверены — совпадают с F7Colors; типографика — Raleway 14/18/20/24; заведены F7Spacing (4dp-сетка) + F7Radius (аудит: не было spacing-токенов, ~1087 инлайновых .dp). docs/DESIGN-SOURCE.md — карта Figma + процесс попиксельной вёрстки. Источник — Figma, боевой forbion НЕ трогаю. Координация — mail/044 | дизайн-система (аддитивно, пока не используется) | нет: новые токены, поведение не меняется
- 2026-07-07 | v0.5.114 (122) | Этап 1 (version catalog): заведён gradle/libs.versions.toml, 15 module build.gradle мигрированы на libs.* (скриптом). Устранён дрейф версий: coroutines 1.8.1/1.10.1→1.10.1, coil 2.6.0/2.7.0→2.7.0, lifecycle 2.8.1/2.8.7→2.8.7, activity-compose 1.9.0/1.10.1→1.10.1, core-ktx 1.13.1/1.15.0→1.15.0. Firebase в core/push переведён на bom (был explicit 24.0.1). Compose BOM в каталоге вместо дубля в каждом модуле. Проверено сборкой | контракты не менялись; версии унифицированы вверх (minor-бампы) | низкий: сборочная гигиена, унификация версий
- 2026-07-07 | v0.5.114 (122) | Этап 1 (статанализ): detekt 1.23.8 + formatting-ruleset (ktlint) на все наши модули (vendor исключён), совместим с Kotlin 2.3. config/detekt/detekt.yml + per-module detekt-baseline.xml (~403 текущих замечаний зафиксированы → зелёный, ловит только новые). Добавлен в CI verify-джобу. Нормальные сборки (assembleDebug/Release) не затрагивает (detekt только в check/CI) | процесс качества | нет: тулинг, кода приложения не трогает
- 2026-07-07 | v0.5.114 (122) | Этап 1 (единый OkHttpClient, ч.2 — 401 централизован): добавлен UnauthorizedInterceptor (401→UnauthorizedException) в NetworkFactory (флаг throwOnUnauthorized, по умолч. true). Удалены 75 ручных проверок `if(code==401) throw` в 16 репозиториях. Login-верификация (AuthVerifier) исключена (throwOnUnauthorized=false: 401=«неверный пароль», не session-expired). Побочно: теперь 401 ловится единообразно ВЕЗДЕ, включая пути, что раньше проверку забывали. +3 теста (MockWebServer: 401 бросает, 200/403 проходят) — всего 18. Проверено сборкой | контракты не менялись; поведение 401 сохранено (interceptor бросает то же исключение) | средний: затрагивает обработку 401 во всех репозиториях; login-путь исключён явно, покрыто тестами
- 2026-07-07 | v0.5.114 (122) | Этап 1 (единый OkHttpClient, ч.1): NetworkFactory переписан — раньше создавал НОВЫЙ OkHttpClient на КАЖДЫЙ запрос (~32 места) → TLS-handshake + новый пул/диспатчер на запрос + утечка ExecutorService. Теперь один базовый клиент (общий ConnectionPool/Dispatcher + диск-кэш 20МБ), авторизованные варианты через base.newBuilder() (шарят пул/кэш), кэшируются по кредам+таймаутам. Публичный API НЕ менялся — 32 вызова не тронуты. Init диск-кэша — F7MobileApp.onCreate. +3 unit-теста (переиспользование клиента, общий пул). 401→interceptor НЕ делал (меняет семантику исключений — отдельный шаг) | контракты не менялись | средний: затрагивает ВСЕ сетевые вызовы (переиспользование соединений); поведение запросов то же, проверено 15 тестами + assembleRelease
- 2026-07-07 | v0.5.114 (122) | Этап 1 (CI + GPL-публикация): добавлен Gitea Actions workflow .gitea/workflows/ci.yml (push/PR → тесты+lint+assembleDebug; тег v* → подписанный release + архив исходников в Gitea Release) — Gitea 1.26.1 поддерживает Actions. Скрипт scripts/package-source.sh (GPL corresponding source §6: архив всего дерева вкл. vendor GPL/LICENSE/NOTICE — проверен, 6.7 МБ) закрывает п.4c, работает и вручную. docs/CI.md. НЕ активно до регистрации self-hosted раннера на инфре B (нужен Gitea-админ для токена — инфра-шаг) | процесс сборки/релиза | нет: конфиг CI, кода приложения не трогает
- 2026-07-07 | v0.5.114 (122) | Этап 1 (баги данных, test-first): заведена тестовая инфраструктура (core:network testImplementation junit) + первые 12 unit-тестов. Починено: (1) ICS-парсер дат — суффикс `Z` теперь UTC (был локальным), учитывается TZID, floating→локаль; (2) parseProps сохраняет параметры свойств — несколько ATTENDEE больше НЕ схлопываются, CN/PARTSTAT/ROLE читаются (была порча участников при редактировании→PUT на сервер); (3) unescape single-pass (последовательные replace ломались на `\\n`); (4) CalDavClient.parseIcsInstant делегирует в CalendarIcs — устранён рассинхрон календарь↔задачи; (5) QR-логин: маркер-парсер вместо split('&') — пароль с `&`/`:` больше не теряется; (6) курсор пагинации почты Int→Long (обрезка Unix-времени, ломалось ~2038). Проверено: 12 тестов зелёные + assembleRelease | контракты не менялись; исправлена запись битых ATTENDEE на сервер | низкий: чинит порчу данных, покрыто тестами
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.5 (gradle-гигиена): org.gradle.parallel=true (ускорение сборки 20 модулей); удалены мёртвые модули core:data, core:ui (пустые заготовки) и feature:widgets (не был в settings.gradle, не компилировался) + чистка settings.gradle. nonTransitiveRClass и убирание jetifier — ПРОБОВАЛ, откатил: vendor-форк talk-android ломается (R транзитивно + legacy android.support.* в его depS); причины в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. **Этап 0 завершён** (кроме п.4c — публикация исходников, к CI этапа 1) | контракты не менялись | низкий: удалён неиспользуемый код, ускорена сборка
- 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.3 (безопасность-минимум): (1) trust-all TLS отключён в release — гейт `if(!BuildConfig.DEBUG)` в UnsafeSsl + обе WebView-точки (OfficeWebViewClient, TalkCallActivity), чекбокс «доверять сертификату» скрыт в release (buildConfig включён в core:network и feature:talk); (2) allowBackup=false (переопределяет vendor true, подтверждено в собранном манифесте); (3) app password шифруется в покое ключом AndroidKeystore AES-256-GCM (KeystoreCrypto) + одноразовая миграция legacy-plaintext при load; (4) ревокация app password при logout (AppPasswordRevoker: DELETE /ocs/v2.php/core/apppassword, best-effort). Биометрическая привязка ключа — отдельная задача (конфликтует с фоновыми push/звонками, нужен кэш в памяти). Проверено сборкой: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false OK | НОВЫЙ вызов OCS: DELETE core/apppassword при logout (стандартный NC endpoint) | средний: миграция хранилища и logout затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
+37 -32
View File
@@ -39,16 +39,14 @@ android {
applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26
targetSdk 36
versionCode 122
versionName '0.5.114'
versionCode 152
versionName '0.5.144'
missingDimensionStrategy 'default', 'f7'
multiDexEnabled true
// Универсальный APK для реальных устройств: только ARM (arm64 + armeabi-v7a),
// без эмуляторных x86/x86_64 — режет ~половину native-веса (WebRTC .so).
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a'
}
// ABI задаём ПОБИЛДТИПОВО (ниже): release — ARM-only (компактно), debug — все ABI
// (включая x86/x86_64), чтобы debug-сборка нативно шла на эмуляторах (BlueStacks и т.п.)
// и на физических устройствах.
// Оставляем только нужные локали (у vendor-форка ~48 языков) — минус несколько МБ.
// resourceConfigurations — не-deprecated бэкинг resConfigs.
resourceConfigurations += ['ru', 'en']
@@ -62,11 +60,16 @@ android {
}
buildTypes {
debug {}
debug {
// Все ABI — чтобы debug нативно работал на x86-эмуляторах (BlueStacks) И на ARM-девайсах.
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64' }
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// прод — только ARM (реальные устройства), компактно
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' }
// боевой keystore — если задан env/-P; иначе debug-ключ (не для распространения)
signingConfig f7HasReleaseSigning ? signingConfigs.release : signingConfigs.debug
}
@@ -104,34 +107,36 @@ 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')
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
def composeBom = platform(libs.compose.bom)
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.navigation:navigation-compose:2.8.9'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
implementation 'io.coil-kt:coil-compose:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
implementation platform('com.google.firebase:firebase-bom:33.7.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.biometric:biometric:1.1.0'
implementation 'androidx.fragment:fragment-ktx:1.8.6'
implementation 'androidx.camera:camera-camera2:1.5.2'
implementation 'androidx.camera:camera-lifecycle:1.5.2'
implementation 'androidx.camera:camera-view:1.5.2'
implementation 'com.google.zxing:core:3.3.0'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'com.google.guava:guava:33.3.1-android'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation libs.activity.compose
implementation libs.compose.ui
implementation libs.compose.ui.tooling.preview
implementation libs.compose.material3
implementation libs.navigation.compose
implementation libs.lifecycle.viewmodel.compose
implementation libs.lifecycle.runtime.compose
implementation libs.lifecycle.process
implementation libs.coil.compose
implementation libs.coil.svg
implementation platform(libs.firebase.bom)
implementation libs.firebase.messaging
implementation libs.coroutines.play.services
implementation libs.multidex
implementation libs.core.splashscreen
implementation libs.biometric
implementation libs.fragment.ktx
implementation libs.camera.camera2
implementation libs.camera.lifecycle
implementation libs.camera.view
implementation libs.zxing
implementation libs.compose.material.icons.extended
implementation libs.guava
debugImplementation libs.compose.ui.tooling
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -37,6 +37,7 @@
android:resource="@mipmap/ic_launcher" />
<activity
android:name=".MainActivity"
android:theme="@style/Theme.F7.Splash"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
*/
class CallIncomingActivity : ComponentActivity() {
private var callEndedListener: ((String) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
finish()
}
// Звонок сняли извне (30-с таймаут «не берут трубку», принят/отклонён из
// уведомления) — закрываем полноэкранный входящий.
launch.roomToken?.takeIf { it.isNotBlank() }?.let { token ->
val listener: (String) -> Unit = { ended ->
if (ended == token) {
runOnUiThread {
if (!isFinishing) finish()
}
}
}
callEndedListener = listener
F7IncomingCallQueue.addCallEndedListener(listener)
}
setContent {
F7Theme {
IncomingCallScreen(
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
}
}
override fun onDestroy() {
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
callEndedListener = null
super.onDestroy()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
@@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.push.F7NotificationChannels
import ru.forbion.f7cloud.core.push.F7PushRegistrar
import ru.forbion.f7cloud.feature.talknative.TalkVendorBootstrap
@@ -37,6 +38,7 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
}
})
F7NotificationChannels.ensureAll(this)
NetworkFactory.init(this) // общий HTTP-клиент + диск-кэш (переиспользование соединений)
TalkVendorBootstrap.onApplicationCreate(this)
val auth = AuthStore(this).load() ?: return
try {
@@ -75,7 +77,31 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
}
override fun newImageLoader(): ImageLoader {
// OkHttp с Basic-auth для хоста сервера: серверные картинки (иконки внешних сайтов
// /apps/external/img, превью и т.п.) требуют логина — без заголовка coil ловит 401.
val authedClient = okhttp3.OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request()
val session = runCatching { AuthStore(this).load() }.getOrNull()
val serverHost = session?.serverUrl?.let {
runCatching { android.net.Uri.parse(it).host }.getOrNull()
}
val patched = if (session != null && serverHost != null && request.url.host == serverHost) {
request.newBuilder()
.header(
"Authorization",
okhttp3.Credentials.basic(session.username, session.appPassword),
)
.header("OCS-APIRequest", "true")
.build()
} else {
request
}
chain.proceed(patched)
}
.build()
return ImageLoader.Builder(this)
.okHttpClient(authedClient)
.components { add(SvgDecoder.Factory()) }
.crossfade(false)
.memoryCache {
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -43,6 +44,8 @@ class MainActivity : FragmentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
// Системный сплэш (фон + иконка) на холодном старте вместо белого экрана
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (!handleIncomingIntent(intent)) {
@@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NextcloudWebSession
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
class OfficeEditorActivity : ComponentActivity() {
@@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() {
if (current == next) return
editorVisible = false
loadError = null
view.loadUrl(target.url)
startLoad(view, target)
}
/**
* Collabora — грузим сразу (iframe самодостаточен по WOPI-токену). Web-viewer (доска и пр.)
* требует НАСТОЯЩУЮ веб-сессию: сначала логинимся app-паролем, переносим cookie в
* WebView CookieManager, только потом открываем URL — иначе viewer видит анонима («нет файлов»).
*/
private fun startLoad(view: WebView, target: OfficeEditorLaunch) {
if (target.collaboraBaseUrl.isNotBlank()) {
view.loadUrl(target.url)
return
}
lifecycleScope.launch {
val ok = runCatching { establishWebSession(target) }.getOrDefault(false)
if (!ok) {
loadError = "Не удалось открыть сессию. Проверьте интернет и попробуйте снова."
return@launch
}
view.loadUrl(target.url)
}
}
/** Логин по форме `/login`, перенос session-cookie в WebView. Возвращает true при успехе. */
private suspend fun establishWebSession(target: OfficeEditorLaunch): Boolean {
val serverUrl = target.serverUrl.ifBlank { target.url }
val cookies = withContext(Dispatchers.IO) {
NextcloudWebSession.login(
serverUrl = serverUrl,
username = target.username,
appPassword = target.password,
trustAllCerts = target.trustAllCerts,
)
}
if (cookies.isEmpty()) return false
val cm = CookieManager.getInstance()
cm.setAcceptCookie(true)
webViewRef?.let { cm.setAcceptThirdPartyCookies(it, true) }
val base = serverUrl.trimEnd('/')
cookies.forEach { c ->
val attrs = buildString {
append("${c.name}=${c.value}; Path=${c.path}")
if (c.secure) append("; Secure")
if (c.httpOnly) append("; HttpOnly")
}
cm.setCookie(base, attrs)
}
withContext(Dispatchers.IO) { cm.flush() }
return true
}
private fun readLaunch(): OfficeEditorLaunch? {
@@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() {
val current = webView.url?.trimEnd('/').orEmpty()
val target = launch.url.trimEnd('/')
if (current != target) {
webView.loadUrl(launch.url)
startLoad(webView, launch)
}
}
},
@@ -346,7 +399,14 @@ class OfficeEditorActivity : ComponentActivity() {
},
onPageReady = { view ->
runOnUiThread {
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
if (launch.collaboraBaseUrl.isNotBlank()) {
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
} else {
// Web-файлы (whiteboard и пр.): Collabora-моста нет — готовность = загрузка
// страницы; chrome НЕ прячем (иначе снесётся весь контейнер приложения).
editorVisible = true
loadError = null
}
}
},
)
@@ -23,12 +23,17 @@ internal class OfficeWebViewClient(
private val onPageReady: (WebView?) -> Unit,
) : WebViewClient() {
// Скрытие chrome forbion — ТОЛЬКО для Collabora-страницы (там документ в отдельном
// #documents-content). На Files-странице (whiteboard и пр.) этот скрипт сносит #body-user
// = весь контейнер приложения → белый экран. Поэтому для web-файлов не инжектим.
private val hideChrome: Boolean get() = launch.collaboraBaseUrl.isNotBlank()
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
}
override fun onPageFinished(view: WebView?, url: String?) {
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
onPageReady(view)
}
@@ -123,11 +128,10 @@ internal class OfficeWebViewClient(
}
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
if (launch.collaboraBaseUrl.isNotBlank()) {
prefixes += launch.collaboraBaseUrl.trimEnd('/')
}
return prefixes.distinct()
// Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии
// (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files.
if (launch.collaboraBaseUrl.isBlank()) return emptyList()
return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct()
}
}
}
@@ -53,6 +53,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.mobile.R
@@ -145,6 +146,7 @@ fun AppLockSetupDialog(
onLockConfigured: () -> Unit = {},
) {
if (!visible) return
F7SecureScreen() // создание/подтверждение PIN — не в скриншотах/recents
val context = LocalContext.current
val activity = context.findFragmentActivity()
@@ -534,6 +536,7 @@ private fun AppLockUnlockScreen(
lockSession: Int,
onUnlocked: () -> Unit,
) {
F7SecureScreen() // экран разблокировки (PIN) — не в скриншотах/recents
val context = LocalContext.current
val activity = context.findFragmentActivity()
var pin by remember { mutableStateOf("") }
@@ -19,7 +19,6 @@ object AppMenuRepository {
val request = Request.Builder().url(url).applyOcsJson().get().build()
return runCatching {
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
return emptyList()
}
@@ -49,7 +48,7 @@ object AppMenuRepository {
}
AppMenuExternalSite(
name = name,
iconUrl = icon ?: defaultExternalIcon(session.serverUrl, name),
iconUrl = resolveExternalIcon(session.serverUrl, icon, name),
openUrl = openUrl,
)
}
@@ -57,12 +56,18 @@ object AppMenuRepository {
}.getOrDefault(emptyList())
}
private fun defaultExternalIcon(serverUrl: String, name: String): String {
/**
* Иконка внешнего сайта. API external отдаёт `icon` как ИМЯ ФАЙЛА (напр. «bitrix24.png»),
* а не URL — сайт грузит его с `/apps/external/img/{icon}` (см. layout.user.php). Раньше
* имя файла бралось как есть → битая ссылка → глобус-заглушка у Bitrix/1C.
*/
private fun resolveExternalIcon(serverUrl: String, icon: String?, name: String): String {
val base = serverUrl.trimEnd('/')
return when {
name.contains("bitrix", ignoreCase = true) -> "$base/themes/forbion/images/header/bitrix-glass.svg"
name.contains("1c", ignoreCase = true) -> "$base/themes/forbion/images/header/1c-glass.svg"
else -> "$base/index.php/apps/external/img/external.svg"
icon.isNullOrBlank() -> "$base/index.php/apps/external/img/external.svg"
icon.startsWith("http") -> icon
icon.startsWith("/") -> "$base$icon"
else -> "$base/index.php/apps/external/img/$icon"
}
}
}
@@ -2,6 +2,10 @@ package ru.forbion.f7cloud.mobile.ui
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
// Иконки шторки — ПЛОСКИЕ menu-иконки живой темы forbion (images/menu/*.svg: зелёный+чёрный
// контур), а не glass-кружки. Локальные (assets/menu/menu-*.svg) — офлайн и 1:1 с сайтом.
private fun menuIcon(name: String): String = "file:///android_asset/menu/menu-$name.svg"
enum class AppTab(
val title: String,
val headerIconPath: String,
@@ -29,16 +33,18 @@ private data class AppMenuEntry(
val webPath: String? = null,
)
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
// iconPath — базовое имя плоской menu-иконки (assets/menu/menu-<name>.svg).
// Состав/порядок 1:1 со шторкой сайта: Файлы·Календарь·Контакты·Карточки·Заметки·Поддержка
// (Почта/Задачи/Конференции не тут — они в нижней панели).
private val coreAppMenuEntries = listOf(
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
AppMenuEntry(AppTab.Files, "Файлы", "files"),
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
)
data class AppMenuExternalSite(
@@ -58,7 +64,7 @@ fun appMenuItems(
val core = coreAppMenuEntries.map { entry ->
F7AppMenuItem(
label = entry.label,
iconUrl = "$base/themes/forbion/images/header/${entry.iconPath}",
iconUrl = menuIcon(entry.iconPath),
selected = entry.tab == active,
externalUrl = entry.webPath?.let { "$base$it" },
)
@@ -71,7 +77,26 @@ fun appMenuItems(
externalUrl = site.openUrl,
)
}
return core + external
// Нативные пункты — плоские menu-иконки темы, действия по label в AppScaffold.
// Порядок как на сайте: после внешних сайтов — Личный кабинет, затем Уведомления, Настройки.
val native = listOf(
F7AppMenuItem(
label = "Личный кабинет",
iconUrl = menuIcon("lk"),
selected = false,
),
F7AppMenuItem(
label = "Уведомления",
iconUrl = menuIcon("notifications"),
selected = false,
),
F7AppMenuItem(
label = "Настройки",
iconUrl = menuIcon("settings"),
selected = false,
),
)
return core + external + native
}
fun appTabFromMenuIndex(index: Int): AppTab? {
@@ -18,28 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -54,12 +38,9 @@ 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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -69,13 +50,10 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import ru.forbion.f7cloud.mobile.BuildConfig
import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.auth.AuthVerifier
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.network.LoginFlowClient
import ru.forbion.f7cloud.core.push.F7PushEvent
import ru.forbion.f7cloud.core.push.F7PushEventHub
@@ -88,14 +66,10 @@ import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
@@ -117,8 +91,8 @@ fun AppScaffold(
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
) {
val context = LocalContext.current
val authStore = remember { AuthStore(context) }
var session by remember { mutableStateOf(authStore.load()) }
val mainVm: MainViewModel = viewModel()
val session by mainVm.session.collectAsState()
var activeTab by rememberSaveable(
saver = Saver(
save = { state -> state.value.name },
@@ -160,6 +134,7 @@ fun AppScaffold(
var filesUploadRequest by remember { mutableIntStateOf(0) }
var contactsCreateRequest by remember { mutableIntStateOf(0) }
var tasksCreateRequest by remember { mutableIntStateOf(0) }
var tasksShowListsRequest by remember { mutableIntStateOf(0) }
var supportCreateRequest by remember { mutableIntStateOf(0) }
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
var talkChatsRequest by remember { mutableIntStateOf(0) }
@@ -173,17 +148,7 @@ fun AppScaffold(
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
}
val logoutScope = rememberCoroutineScope()
val forceLogout = {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
session?.let { current ->
logoutScope.launch { AppPasswordRevoker.revoke(current) }
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
authStore.clear()
session = null
}
val forceLogout = { mainVm.logout() }
LaunchedEffect(session?.serverUrl, session?.username) {
session?.let { OfficeWarmup.warm(it) }
}
@@ -196,10 +161,7 @@ fun AppScaffold(
F7Theme {
if (session == null) {
LoginScreen(onLogin = {
authStore.save(it)
session = it
})
LoginScreen(onLogin = { mainVm.login(it) })
return@F7Theme
}
@@ -483,6 +445,7 @@ fun AppScaffold(
serverUrl = currentSession.serverUrl,
userId = userId,
config = bottomBarConfig,
activeTabKey = activeTab.name,
menuOpen = menuOpen,
chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom,
navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) ||
@@ -496,39 +459,30 @@ fun AppScaffold(
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
else -> Unit
// Задачи: стрелка открывает панель списков (закрывает
// открытый список) — как сайдбар в Почте/Файлах.
AppTab.Tasks -> tasksShowListsRequest++
// Разделы без сайдбара (Карточки/Конференции/Контакты/
// Поддержка): стрелка = «назад» к предыдущему разделу.
else -> popTabHistory()?.let { activeTab = it }
}
},
onCreateClick = {
when (activeTab) {
AppTab.Files -> {
if (F7AppPermissions.missing(context).isNotEmpty()) {
onRequestRuntimePermissions { filesUploadRequest++ }
} else {
filesUploadRequest++
}
}
AppTab.Contacts -> contactsCreateRequest++
AppTab.Tasks -> tasksCreateRequest++
AppTab.Support -> supportCreateRequest++
else -> Unit
onMailClick = {
if (activeTab != AppTab.Mail) {
pushTabHistory(activeTab)
activeTab = AppTab.Mail
}
},
onProfileClick = {
menuOpen = false
profileOpen = true
onTasksClick = {
if (activeTab != AppTab.Tasks) {
pushTabHistory(activeTab)
activeTab = AppTab.Tasks
}
},
onNotificationsClick = {
menuOpen = false
hasNotificationBadge = false
notificationsOpen = true
},
onSettingsClick = {
when (activeTab) {
AppTab.Mail -> mailSettingsOpen = true
AppTab.Calendar -> calendarSettingsRequest++
AppTab.Files -> filesSettingsOpen = true
else -> Unit
onConferencesClick = {
if (activeTab != AppTab.Talk) {
pushTabHistory(activeTab)
activeTab = AppTab.Talk
}
},
onMenuClick = { menuOpen = !menuOpen },
@@ -616,6 +570,7 @@ fun AppScaffold(
modifier = contentModifier,
createRequest = tasksCreateRequest,
openListSlug = pendingTasksListSlug,
showListsRequest = tasksShowListsRequest,
onOpenListConsumed = { pendingTasksListSlug = null },
onUnauthorized = forceLogout,
)
@@ -637,19 +592,43 @@ fun AppScaffold(
onItemClick = { index ->
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
val external = item.externalUrl
if (!external.isNullOrBlank()) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
val label = item.label.trim()
when {
// Профиль — нативная панель вместо веб-ЛК forbion
label.equals("Личный кабинет", ignoreCase = true) -> {
menuOpen = false
profileOpen = true
}
menuOpen = false
} else {
appTabFromMenuIndex(index)?.let { tab ->
if (tab != activeTab) {
pushTabHistory(activeTab)
activeTab = tab
// Нативные пункты по дизайну меню
label.equals("Уведомления", ignoreCase = true) -> {
menuOpen = false
hasNotificationBadge = false
notificationsOpen = true
}
label.equals("Настройки", ignoreCase = true) -> {
menuOpen = false
when (activeTab) {
AppTab.Mail -> mailSettingsOpen = true
AppTab.Calendar -> calendarSettingsRequest++
AppTab.Files -> filesSettingsOpen = true
else -> Unit
}
}
menuOpen = false
!external.isNullOrBlank() -> {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
}
menuOpen = false
}
else -> {
appTabFromMenuIndex(index)?.let { tab ->
if (tab != activeTab) {
pushTabHistory(activeTab)
activeTab = tab
}
}
menuOpen = false
}
}
},
)
@@ -677,254 +656,3 @@ fun AppScaffold(
}
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
@Composable
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
var loading by rememberSaveable { mutableStateOf(false) }
var error by rememberSaveable { mutableStateOf<String?>(null) }
val qrLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
?: return@rememberLauncherForActivityResult
scope.launch {
loading = true
error = null
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
if (loginResult == null) {
error = if (qrData.contains("/login/v2/flow/")) {
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
} else {
"Не удалось распознать QR-код"
}
loading = false
return@launch
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(loginResult.serverUrl),
username = loginResult.username,
appPassword = loginResult.appPassword,
trustAllCerts = trustAllCerts,
)
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа по QR" }
loading = false
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
}
}
fun launchQrScan() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
} else {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
fun submitLogin() {
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
return
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(serverUrl),
username = username.trim(),
appPassword = password,
trustAllCerts = trustAllCerts,
)
scope.launch {
loading = true
error = null
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа" }
loading = false
}
}
val logoUrl = remember(serverUrl) {
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
}
Box(
modifier = Modifier
.fillMaxSize()
.f7SafeTopInsets()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 24.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 440.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = logoUrl,
contentDescription = "Forbion",
modifier = Modifier
.width(300.dp)
.height(70.dp)
.padding(bottom = 48.dp),
contentScale = ContentScale.Fit,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = F7Colors.Background,
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Вход",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(bottom = 12.dp),
)
Text(
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(bottom = 8.dp),
)
F7OutlinedField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = "Адрес сервера",
modifier = Modifier
.focusRequester(serverFocus)
.focusProperties { next = usernameFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { usernameFocus.requestFocus() },
),
onEnter = { usernameFocus.requestFocus() },
)
F7OutlinedField(
value = username,
onValueChange = { username = it },
label = "Имя пользователя",
modifier = Modifier
.focusRequester(usernameFocus)
.focusProperties { next = passwordFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() },
),
onEnter = { passwordFocus.requestFocus() },
)
F7OutlinedField(
value = password,
onValueChange = { password = it },
label = "Пароль",
modifier = Modifier.focusRequester(passwordFocus),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
onGo = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
),
onEnter = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
)
// Чекбокс trust-all показываем только в debug-сборках: в release он
// всё равно игнорируется на уровне сети/WebView (см. UnsafeSsl).
if (BuildConfig.DEBUG) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = trustAllCerts,
onCheckedChange = { trustAllCerts = it },
)
Text(
text = "Доверять сертификату (dev)",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
}
Spacer(Modifier.height(8.dp))
F7PrimaryButton(
text = if (loading) "" else "Войти",
onClick = { submitLogin() },
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
)
F7TextButton(
text = "Сканировать QR для входа",
onClick = { launchQrScan() },
enabled = !loading,
modifier = Modifier.fillMaxWidth(),
)
if (loading) {
CircularProgressIndicator(color = F7Colors.Primary)
}
if (error != null) {
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
F7TextButton(
text = "Очистить",
onClick = {
error = null
password = ""
},
)
}
}
}
}
}
}
@@ -1,17 +1,38 @@
package ru.forbion.f7cloud.mobile.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -21,22 +42,30 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Credentials
import okhttp3.Request
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
import ru.forbion.f7cloud.core.network.F7Notification
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NotificationsRepository
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
@@ -54,46 +83,178 @@ fun ProfileSheet(
onLogout: () -> Unit,
onScanBrowserQr: () -> Unit = {},
) {
val context = LocalContext.current
val base = session.serverUrl.trimEnd('/')
val userId = session.davUserId ?: session.username
var displayName by remember(session.username) { mutableStateOf(userId) }
var aboutOpen by remember { mutableStateOf(false) }
// Отображаемое имя тянем с сервера (OCS), пока — логин
LaunchedEffect(visible, session.username) {
if (visible) displayName = fetchDisplayName(session).ifBlank { userId }
}
fun openWeb(path: String) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("$base$path")))
}
onDismiss()
}
F7FloatingPanel(
visible = visible,
onDismiss = onDismiss,
) {
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
Text(
text = session.username,
style = MaterialTheme.typography.bodyLarge,
color = F7Colors.TextPrimary,
)
Text(
text = session.serverUrl,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
session.davUserId?.let { davId ->
Text(
text = "ID: $davId",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка QR
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight),
contentAlignment = Alignment.Center,
) {
Text(
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.Primary,
)
}
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.clickable { openWeb("/u/$userId") }
.padding(vertical = 2.dp),
) {
Text(
displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Открыть профиль",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.Primary,
)
}
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.Grey2)
.clickable {
onDismiss()
onScanBrowserQr()
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.QrCodeScanner,
contentDescription = "Сканировать QR браузера",
tint = F7Colors.TextSecondary,
modifier = Modifier.size(22.dp),
)
}
}
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
Spacer(Modifier.height(4.dp))
ProfileMenuRow(Icons.Filled.TaskAlt, "Установить статус") { openWeb("/settings/user") }
ProfileMenuRow(Icons.Filled.Person, "Личные настройки") { openWeb("/settings/user") }
ProfileMenuRow(Icons.Filled.Group, "Учётные записи") { openWeb("/settings/user/security") }
ProfileMenuRow(Icons.Outlined.Info, "О программе и что нового") { aboutOpen = true }
ProfileMenuRow(
icon = Icons.AutoMirrored.Filled.Logout,
label = "Выйти",
tint = F7Colors.Error,
) {
onDismiss()
onLogout()
}
F7PrimaryButton(
text = "Сканировать QR браузера",
onClick = {
onDismiss()
onScanBrowserQr()
},
modifier = Modifier.fillMaxWidth(),
)
F7PrimaryButton(
text = "Выйти",
onClick = {
onDismiss()
onLogout()
},
modifier = Modifier.fillMaxWidth(),
)
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
}
if (aboutOpen) {
F7AboutDialog(onDismiss = { aboutOpen = false })
}
}
@Composable
private fun ProfileMenuRow(
icon: ImageVector,
label: String,
tint: Color = F7Colors.TextPrimary,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
Text(label, style = MaterialTheme.typography.bodyLarge, color = tint)
}
}
@Composable
private fun F7AboutDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val version = remember {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: ""
}
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text("Закрыть") }
},
title = { Text("F7cloud Mobile") },
text = {
Column {
Text("Версия $version", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
Spacer(Modifier.height(8.dp))
Text(
"Нативный клиент F7cloud: почта, файлы, календарь, контакты, задачи, конференции, поддержка.",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
},
)
}
/** Отображаемое имя пользователя из OCS (fallback — пусто). */
private suspend fun fetchDisplayName(session: AuthSession): String = withContext(Dispatchers.IO) {
runCatching {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
JSONObject(body)
.optJSONObject("ocs")
?.optJSONObject("data")
?.optString("displayname")
.orEmpty()
}
}.getOrDefault("")
}
@Composable
@@ -0,0 +1,312 @@
package ru.forbion.f7cloud.mobile.ui
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthVerifier
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
import ru.forbion.f7cloud.core.network.LoginFlowClient
import ru.forbion.f7cloud.mobile.BuildConfig
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
@Composable
internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
F7SecureScreen() // ввод пароля — не в скриншотах/recents
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
var loading by rememberSaveable { mutableStateOf(false) }
var error by rememberSaveable { mutableStateOf<String?>(null) }
val qrLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
?: return@rememberLauncherForActivityResult
scope.launch {
loading = true
error = null
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
if (loginResult == null) {
error = if (qrData.contains("/login/v2/flow/")) {
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
} else {
"Не удалось распознать QR-код"
}
loading = false
return@launch
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(loginResult.serverUrl),
username = loginResult.username,
appPassword = loginResult.appPassword,
trustAllCerts = trustAllCerts,
)
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа по QR" }
loading = false
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
}
}
fun launchQrScan() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
} else {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
fun submitLogin() {
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
return
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(serverUrl),
username = username.trim(),
appPassword = password,
trustAllCerts = trustAllCerts,
)
scope.launch {
loading = true
error = null
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа" }
loading = false
}
}
// Логотип — локальный (assets/login/big-forbion.svg из живой темы): виден офлайн и до
// ввода адреса сервера.
val logoUrl = "file:///android_asset/login/big-forbion.svg"
Box(
modifier = Modifier
.fillMaxSize()
// Живая тема логина: фон страницы --backgroud-color-main-darkgray
.background(Color(0xFFE5EFE8))
.f7SafeTopInsets()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 24.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 440.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = logoUrl,
contentDescription = "Forbion",
modifier = Modifier
.width(300.dp)
.height(70.dp)
.padding(bottom = 48.dp),
contentScale = ContentScale.Fit,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = F7Colors.Background,
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Вход",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(bottom = 12.dp),
)
Text(
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(bottom = 8.dp),
)
F7OutlinedField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = "Адрес сервера",
modifier = Modifier
.focusRequester(serverFocus)
.focusProperties { next = usernameFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { usernameFocus.requestFocus() },
),
onEnter = { usernameFocus.requestFocus() },
)
F7OutlinedField(
value = username,
onValueChange = { username = it },
label = "Имя пользователя",
modifier = Modifier
.focusRequester(usernameFocus)
.focusProperties { next = passwordFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() },
),
onEnter = { passwordFocus.requestFocus() },
)
F7OutlinedField(
value = password,
onValueChange = { password = it },
label = "Пароль",
modifier = Modifier.focusRequester(passwordFocus),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
onGo = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
),
onEnter = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
)
Spacer(Modifier.height(8.dp))
F7PrimaryButton(
text = if (loading) "" else "Войти",
onClick = { submitLogin() },
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
)
// Живая тема: вторичная кнопка-ссылка 40dp, фон #F0F1F4, r8, текст 14 чёрный
Box(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFF0F1F4))
.clickable(enabled = !loading, onClick = { launchQrScan() }),
contentAlignment = Alignment.Center,
) {
Text(
"Сканировать QR для входа",
fontSize = 14.sp,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
)
}
if (loading) {
CircularProgressIndicator(color = F7Colors.Primary)
}
if (error != null) {
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
F7TextButton(
text = "Очистить",
onClick = {
error = null
password = ""
},
)
}
}
}
}
}
}
@@ -0,0 +1,41 @@
package ru.forbion.f7cloud.mobile.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
/**
* Держит session/auth-жизненный цикл верхнего уровня (вынесено из AppScaffold).
* В ViewModel сессия переживает пересоздание Activity (поворот и т.п.) — раньше
* жила в remember и перечитывалась из AuthStore.
*/
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val authStore = AuthStore(app)
private val _session = MutableStateFlow(authStore.load())
val session: StateFlow<AuthSession?> = _session.asStateFlow()
fun login(newSession: AuthSession) {
authStore.save(newSession)
_session.value = newSession
}
fun logout() {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
_session.value?.let { current ->
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
authStore.clear()
_session.value = null
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Логотип F7 для сплэша: вектор из темы forbion (logo-header.svg) вместо растянутого
растра лаунчер-иконки (мыло). Глиф (35×32, контент x 2.2633.53 / y 5.0125.99)
отцентрирован в центральных 2/3 квадрата 48 — безопасная зона круглой маски сплэша. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="48"
android:viewportHeight="48">
<group
android:scaleX="1.0233"
android:scaleY="1.0233"
android:translateX="5.69"
android:translateY="8.14">
<path
android:fillColor="#151515"
android:pathData="M8.33398 18.9808V25.9711C8.33398 25.9761 8.33209 25.981 8.32873 25.9845C8.32537 25.9881 8.3208 25.9901 8.31605 25.9901H2.27416C2.2718 25.9901 2.26947 25.9896 2.2673 25.9887C2.26512 25.9877 2.26314 25.9863 2.26148 25.9845C2.25812 25.981 2.25623 25.9761 2.25623 25.9711V5.02973C2.25623 5.02724 2.25669 5.02477 2.25759 5.02246C2.25849 5.02016 2.25981 5.01807 2.26148 5.0163C2.26484 5.01274 2.2694 5.01074 2.27416 5.01074H19.7382C19.7415 5.01074 19.7447 5.01166 19.7475 5.01343C19.7503 5.0152 19.7526 5.01773 19.7541 5.02076C19.7556 5.0238 19.7564 5.02722 19.7562 5.03065C19.756 5.03409 19.755 5.03742 19.7532 5.04028L16.2595 10.7647C16.2578 10.7674 16.2555 10.7696 16.2528 10.771C16.2501 10.7725 16.2471 10.7732 16.244 10.7731H8.35192C8.34716 10.7731 8.3426 10.7751 8.33924 10.7787C8.33587 10.7823 8.33398 10.7871 8.33398 10.7921V13.1672C8.33398 13.1723 8.33587 13.1771 8.33924 13.1807C8.3426 13.1842 8.34716 13.1862 8.35192 13.1862H14.9303C14.9328 13.1862 14.9352 13.1867 14.9374 13.1878C14.9397 13.1888 14.9417 13.1903 14.9434 13.1922C14.9451 13.194 14.9464 13.1963 14.9472 13.1987C14.948 13.2011 14.9484 13.2037 14.9483 13.2063L14.6937 18.9439C14.6935 18.9487 14.6915 18.9533 14.6881 18.9566C14.6848 18.96 14.6804 18.9618 14.6758 18.9618H8.35192C8.34716 18.9618 8.3426 18.9638 8.33924 18.9674C8.33587 18.9709 8.33398 18.9758 8.33398 18.9808Z" />
<path
android:fillColor="#70B62B"
android:pathData="M26.2675 10.8048C26.2877 10.7837 26.2836 10.7731 26.255 10.7731H17.7975C17.7762 10.7731 17.7714 10.7638 17.783 10.7452L21.3221 5.03395C21.3317 5.01848 21.3453 5.01074 21.3629 5.01074H33.5155C33.5188 5.01074 33.5219 5.01213 33.5243 5.0146C33.5266 5.01708 33.5279 5.02043 33.5279 5.02393V10.7494C33.5279 10.7673 33.5218 10.7826 33.5095 10.7953C31.0839 13.3392 28.8027 16.2518 27.1169 19.4365C26.3193 20.944 25.6677 22.6434 25.4834 24.3571C25.4296 24.8592 25.4445 25.4268 25.445 25.9632C25.445 25.9811 25.4367 25.9901 25.4201 25.9901H18.4337C18.4164 25.9901 18.4072 25.981 18.4063 25.9627C18.3571 24.9535 18.4315 23.9955 18.6294 23.0886C19.0753 21.0468 20.0567 19.0346 21.1572 17.2724C22.6248 14.9221 24.3809 12.7806 26.2675 10.8048Z" />
</group>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Сплэш при холодном старте (androidx core-splashscreen): фон в цвет
F7Colors.Background + векторный логотип F7 (лаунчер-иконка — растянутый
растр, мылится на сплэше); после — прежняя тема. -->
<style name="Theme.F7.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FBFBFB</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/f7_splash_logo</item>
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
</style>
</resources>
+17
View File
@@ -8,6 +8,23 @@ plugins {
id 'org.jetbrains.kotlin.plugin.parcelize' version '2.3.0' apply false
id 'com.google.devtools.ksp' version '2.3.4' apply false
id 'com.google.gms.google-services' version '4.4.2' apply false
id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
}
ext.kotlinVersion = '2.3.0'
// Detekt на НАШИХ модулях (vendor-форк talk-android не линтуем — чужой код).
subprojects {
if (path != ':vendor:talk-app') {
apply plugin: 'io.gitlab.arturbosch.detekt'
detekt {
buildUponDefaultConfig = true
parallel = true
config.setFrom(rootProject.file('config/detekt/detekt.yml'))
baseline = file("$projectDir/detekt-baseline.xml")
}
dependencies {
detektPlugins 'io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8'
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# Detekt — статанализ + форматирование (formatting-ruleset = обёртка ktlint).
# buildUponDefaultConfig=true в build.gradle → это дополняет дефолты detekt.
# Подход адаптации на существующий код: baseline фиксирует текущие замечания,
# сборка падает только на НОВЫХ. Постепенно расчищаем baseline.
build:
maxIssues: 0 # с baseline: только новые замечания валят
complexity:
LongMethod:
active: true
threshold: 120 # у нас есть большие composable — не хотим шум сверх меры
LongParameterList:
active: false # Compose-функции с многими колбэками — норма
TooManyFunctions:
active: false
CyclomaticComplexMethod:
threshold: 25
style:
MaxLineLength:
maxLineLength: 140
MagicNumber:
active: false # в UI-коде много dp/цветов — отдельная задача (дизайн-токены)
ReturnCount:
active: false
UnusedPrivateMember:
active: true
exceptions:
TooGenericExceptionCaught:
active: false # много catch(Throwable) в фоновых путях — осознанно
SwallowedException:
active: false
naming:
FunctionNaming:
active: false # @Composable функции с Заглавной — стандарт Compose
+1 -1
View File
@@ -21,5 +21,5 @@ android {
dependencies {
implementation project(':core:network')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation libs.coroutines.android
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:AuthErrors.kt$this is SSLPeerUnverifiedException || this is SSLHandshakeException || this is CertPathValidatorException || this is SSLException || msg.contains("not verified", ignoreCase = true) || msg.contains("CertificateException", ignoreCase = true) || msg.contains("Trust anchor", ignoreCase = true)</ID>
<ID>NoUnusedImports:OcsUserResolver.kt$ru.forbion.f7cloud.core.auth.OcsUserResolver.kt</ID>
</CurrentIssues>
</SmellBaseline>
@@ -18,6 +18,8 @@ object AuthVerifier {
session.username,
session.appPassword,
session.trustAllCerts,
// login-верификация: 401 = «неверный пароль» со своим сообщением, НЕ session-expired
throwOnUnauthorized = false,
)
val request = Request.Builder()
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
@@ -22,9 +22,6 @@ object OcsUserResolver {
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
if (!response.isSuccessful || response.body == null) {
error("User profile HTTP ${response.code}")
}
-26
View File
@@ -1,26 +0,0 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.data'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:database')
implementation project(':core:network')
}
-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
+3 -3
View File
@@ -21,7 +21,7 @@ android {
}
dependencies {
api 'androidx.room:room-runtime:2.7.2'
implementation 'androidx.room:room-ktx:2.7.2'
ksp 'androidx.room:room-compiler:2.7.2'
api libs.room.runtime
implementation libs.room.ktx
ksp libs.room.compiler
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:FilesDao.kt$FilesDao$("SELECT * FROM files WHERE serverUrl = :serverUrl AND username = :username ORDER BY isDirectory DESC, name ASC")</ID>
<ID>MaximumLineLength:FilesDao.kt$FilesDao$ </ID>
</CurrentIssues>
</SmellBaseline>
@@ -24,4 +24,8 @@ data class ContactEntity(
val birthday: String = "",
val emails: String = "",
val phones: String = "",
// Для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
val href: String = "",
val etag: String = "",
val raw: String = "",
)
@@ -36,6 +36,9 @@ interface ContactsDao {
@Query("DELETE FROM contacts WHERE accountKey = :accountKey")
suspend fun deleteAll(accountKey: String)
@Query("DELETE FROM contacts WHERE accountKey = :accountKey AND uid = :uid")
suspend fun deleteByUid(accountKey: String, uid: String)
@Transaction
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
deleteAll(accountKey)
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [FileEntity::class, ContactEntity::class],
version = 4,
version = 5,
exportSchema = false,
)
abstract class F7Database : RoomDatabase() {
@@ -64,6 +64,15 @@ abstract class F7Database : RoomDatabase() {
}
}
// P1 Контакты (b-mob-contacts): href/etag/raw для правки/удаления (If-Match) и round-trip vCard.
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE contacts ADD COLUMN href TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN etag TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN raw TEXT NOT NULL DEFAULT ''")
}
}
fun get(context: Context): F7Database {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
@@ -71,7 +80,7 @@ abstract class F7Database : RoomDatabase() {
F7Database::class.java,
"f7cloud-mobile.db",
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build()
.also { INSTANCE = it }
}
+6 -6
View File
@@ -25,11 +25,11 @@ android {
}
dependencies {
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
def composeBom = platform(libs.compose.bom)
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.material3:material3'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
implementation libs.compose.ui
implementation libs.compose.foundation
implementation libs.compose.material3
implementation libs.coil.compose
implementation libs.coil.svg
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp)</ID>
<ID>ImportOrdering:F7Components.kt$import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.OutlinedButton import androidx.compose.material3.TextButton import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog</ID>
<ID>LoopWithTooManyJumpStatements:F7OverlayNavigation.kt$while</ID>
<ID>MatchingDeclarationName:F7MobileBottomBar.kt$F7BottomBarActions</ID>
<ID>MatchingDeclarationName:F7OverlayNavigation.kt$F7OverlayNavigationScope</ID>
<ID>MaximumLineLength:F7Typography.kt$ </ID>
<ID>NoUnusedImports:F7NotificationRow.kt$ru.forbion.f7cloud.core.designsystem.F7NotificationRow.kt</ID>
<ID>NoUnusedImports:F7Typography.kt$ru.forbion.f7cloud.core.designsystem.F7Typography.kt</ID>
<ID>UnusedParameter:F7AppMenuSheet.kt$onDismiss: () -&gt; Unit</ID>
<ID>UnusedParameter:F7MobileBottomBar.kt$userId: String</ID>
</CurrentIssues>
</SmellBaseline>
@@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1460_27244)">
<path d="M3 3H13.5341" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 13H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1460_27244">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 570 B

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
<path d="M1 26.2893V9H29V26.2893C29 27.7864 27.7864 29 26.2893 29H3.71067C2.21361 29 1 27.7864 1 26.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
<path d="M1 9V5.71067C1 4.21361 2.21361 3 3.71067 3H26.2893C27.7864 3 29 4.21361 29 5.71067V9H1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
<path d="M6.32233 1H5.67767C5.3034 1 5 1.3034 5 1.67767V5.32233C5 5.6966 5.3034 6 5.67767 6H6.32233C6.6966 6 7 5.6966 7 5.32233V1.67767C7 1.3034 6.6966 1 6.32233 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
<path d="M12.3223 1H11.6777C11.3034 1 11 1.3034 11 1.67767V5.32233C11 5.6966 11.3034 6 11.6777 6H12.3223C12.6966 6 13 5.6966 13 5.32233V1.67767C13 1.3034 12.6966 1 12.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
<path d="M18.3223 1H17.6777C17.3034 1 17 1.3034 17 1.67767V5.32233C17 5.6966 17.3034 6 17.6777 6H18.3223C18.6966 6 19 5.6966 19 5.32233V1.67767C19 1.3034 18.6966 1 18.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
<path d="M24.3223 1H23.6777C23.3034 1 23 1.3034 23 1.67767V5.32233C23 5.6966 23.3034 6 23.6777 6H24.3223C24.6966 6 25 5.6966 25 5.32233V1.67767C25 1.3034 24.6966 1 24.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
<path
d="M14.5436 21.844V23H10.1415V21.844H11.7412V15.9356C11.679 16.029 11.5739 16.1341 11.426 16.2509C11.2858 16.3598 11.1263 16.4688 10.9472 16.5778C10.7682 16.679 10.5813 16.7646 10.3867 16.8347C10.1999 16.9048 10.0287 16.9398 9.87296 16.9398V15.7488C10.0831 15.7488 10.2933 15.6943 10.5035 15.5853C10.7215 15.4763 10.9239 15.3517 11.1107 15.2116C11.2975 15.0637 11.4454 14.9314 11.5544 14.8146C11.6712 14.6979 11.7335 14.6356 11.7412 14.6278H13.0607V21.844H14.5436ZM18.0332 23.0934C17.5739 23.0934 17.1535 23.0272 16.7721 22.8949C16.3984 22.7626 16.0832 22.5719 15.8263 22.3228C15.5694 22.0736 15.3825 21.7856 15.2658 21.4587L15.9664 20.6179C16.0209 20.8203 16.1338 21.0227 16.305 21.2251C16.4841 21.4275 16.7137 21.591 16.9939 21.7156C17.282 21.8401 17.6167 21.9024 17.9981 21.9024C18.3329 21.9024 18.6287 21.8518 18.8856 21.7506C19.1502 21.6416 19.3604 21.4898 19.5161 21.2952C19.6718 21.1006 19.7496 20.8709 19.7496 20.6063C19.7496 20.3105 19.6562 20.0614 19.4694 19.859C19.2826 19.6566 19.014 19.5048 18.6637 19.4036C18.3134 19.3024 17.893 19.2518 17.4026 19.2518H17.0056V18.2242H17.4026C18.0487 18.2242 18.5625 18.1191 18.9439 17.909C19.3332 17.6988 19.5278 17.3874 19.5278 16.9748C19.5278 16.7179 19.4577 16.4961 19.3176 16.3092C19.1775 16.1146 18.9945 15.9706 18.7688 15.8772C18.543 15.776 18.2939 15.7254 18.0215 15.7254C17.5778 15.7254 17.1924 15.8266 16.8655 16.029C16.5385 16.2314 16.3011 16.4805 16.1532 16.7763L15.3125 15.8538C15.476 15.597 15.7017 15.3751 15.9897 15.1883C16.2778 15.0014 16.6008 14.8574 16.9589 14.7562C17.3248 14.655 17.6945 14.6044 18.0682 14.6044C18.6131 14.6044 19.0996 14.7017 19.5278 14.8964C19.9559 15.091 20.2907 15.3595 20.532 15.7021C20.7733 16.0368 20.894 16.4182 20.894 16.8464C20.894 17.1577 20.8278 17.438 20.6955 17.6871C20.5709 17.9362 20.388 18.1464 20.1466 18.3176C19.9131 18.4811 19.6329 18.6018 19.3059 18.6796C19.6562 18.7419 19.9637 18.8742 20.2284 19.0766C20.5008 19.279 20.7071 19.5281 20.8473 19.8239C20.9952 20.1197 21.0691 20.4428 21.0691 20.7931C21.0691 21.2757 20.929 21.6883 20.6487 22.0308C20.3763 22.3733 20.0104 22.638 19.5511 22.8248C19.0919 23.0039 18.5859 23.0934 18.0332 23.0934Z"
fill="#70B62B"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="27" viewBox="0 0 28 27"
fill="none">
<g clip-path="url(#clip0_4701_11222)">
<rect width="28" height="27" fill="white"/>
<path d="M1 23.2893V3.71C1 2.21331 2.21331 1 3.71 1H24.29C25.7867 1 27 2.21331 27 3.71V23.2893C27 24.7864 25.7864 26 24.2893 26H3.71067C2.21361 26 1 24.7864 1 23.2893Z"
fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
<path d="M3.5 6L8.5 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
<path d="M19.5 6L24.5756 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
<path d="M11.5 6L16.681 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
<rect x="3.45" y="20.55" width="12.1" height="5.1" rx="0.55" transform="rotate(-90 3.45 20.55)" stroke="black"
stroke-width="0.9"/>
<rect x="19.45" y="20.55" width="12.1" height="5.1" rx="0.55" transform="rotate(-90 19.45 20.55)" stroke="black"
stroke-width="0.9"/>
<rect x="11.45" y="13.55" width="5.1" height="5.1" rx="0.55" transform="rotate(-90 11.45 13.55)" stroke="black"
stroke-width="0.9"/>
</g>
<defs>
<clipPath id="clip0_4701_11222">
<rect width="28" height="27" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="30" viewBox="0 0 29 30" fill="none">
<path
d="M28 14.5063V3.86241C28 3.14045 27.1472 2.75719 26.6073 3.23654L19.5114 9.53691C19.0911 9.91008 19.1508 10.5832 19.6301 10.8766L26.7261 15.2202C27.2838 15.5616 28 15.1602 28 14.5063Z"
fill="#F5F5F5"
stroke="black"
stroke-width="0.836976"
/>
<path d="M0 2.51093C0 1.12418 1.12418 0 2.51093 0H18.4891C19.8758 0 21 1.12418 21 2.51093V11V15.4891C21 16.8758 19.8758 18 18.4891 18H2.51093C1.12418 18 0 16.8758 0 15.4891V2.51093Z" fill="#F5F5F5" />
<path
d="M0.418488 2.51093C0.418488 1.35531 1.3553 0.418488 2.51093 0.418488H18.4891C19.6447 0.418488 20.5815 1.3553 20.5815 2.51093V11V15.4891C20.5815 16.6447 19.6447 17.5815 18.4891 17.5815H2.51093C1.35531 17.5815 0.418488 16.6447 0.418488 15.4891V2.51093Z"
stroke="black"
stroke-width="0.836976"
/>
<line x1="2.33337" y1="12.536" x2="17.8889" y2="12.536" stroke="#70B62B" stroke-width="1.67395" />
<rect x="17.1111" y="3.08606" width="1.55556" height="2.05738" fill="#70B62B" />
<path d="M0.941024 25.501L7.49997 17.6557L10.3173 17.7274L2.57507 27.2897C2.23834 27.7056 1.68223 27.7818 1.23338 27.4736C0.606407 27.043 0.462663 26.0731 0.941024 25.501Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" />
<path d="M18.5966 27.3311L10.6298 17.6627L13.6298 17.6627L20.4548 26.2201C20.7632 26.6068 20.7068 27.1272 20.3206 27.4581C19.8242 27.8833 19.0017 27.8227 18.5966 27.3311Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" />
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="29" viewBox="0 0 26 29" fill="none">
<path d="M4 25.2893V3.71C4 2.21331 5.21331 1 6.71 1H22.29C23.7867 1 25 2.21331 25 3.71V25.2893C25 26.7864 23.7864 28 22.2893 28H6.71067C5.21361 28 4 26.7864 4 25.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
<path d="M4 26V3C4 1.89543 4.89543 1 6 1H8V28H6C4.89543 28 4 27.1046 4 26Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
<path d="M1.16211 13.6755V14.3201C1.16211 14.6944 1.46551 14.9978 1.83978 14.9978H5.48444C5.85871 14.9978 6.16211 14.6944 6.16211 14.3201V13.6755C6.16211 13.3012 5.85871 12.9978 5.48444 12.9978H1.83978C1.46551 12.9978 1.16211 13.3012 1.16211 13.6755Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
<path d="M1.16211 19.6777V20.3223C1.16211 20.6966 1.46551 21 1.83978 21H5.48444C5.85871 21 6.16211 20.6966 6.16211 20.3223V19.6777C6.16211 19.3034 5.85871 19 5.48444 19H1.83978C1.46551 19 1.16211 19.3034 1.16211 19.6777Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
<path d="M1 7.67767V8.32233C1 8.6966 1.3034 9 1.67767 9H5.32233C5.6966 9 6 8.6966 6 8.32233V7.67767C6 7.3034 5.6966 7 5.32233 7L1.67767 7C1.3034 7 1 7.3034 1 7.67767Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
<circle cx="16.5" cy="12.4375" r="2.0625" fill="#70B62B"></circle>
<path d="M16.5 15.1875C14.1966 15.1875 12.8578 16.9963 12.4838 18.0888C12.3854 18.3761 12.6212 18.625 12.925 18.625H20.075C20.3788 18.625 20.6146 18.3761 20.5162 18.0888C20.1422 16.9963 18.8034 15.1875 16.5 15.1875Z" fill="#70B62B"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
<path
d="M28 12.6762V19.3238C28 20.8018 26.8018 22 25.3237 22H3.67625C2.1982 22 1 20.8018 1 19.3238V9.67625C1 8.1982 2.1982 7 3.67625 7H14.8915C15.6012 7 16.282 7.28196 16.7839 7.78386L18.2161 9.21614C18.718 9.71804 19.3988 10 20.1085 10H25.3238C26.8018 10 28 11.1982 28 12.6762Z"
fill="#F5F5F5"
stroke="black"
stroke-width="0.892083"
/>
<path d="M5 1V22H25V1H5Z" fill="#F5F5F5" stroke="black" stroke-width="0.892083" />
<path d="M4 3V25H23V3H4Z" fill="#FCFEFC" stroke="black" stroke-width="0.892083" />
<path
d="M1 17.6762V26.3238C1 27.8018 2.1982 29 3.67625 29H26.3237C27.8018 29 29 27.8018 29 26.3238V14.6762C29 13.1982 27.8018 12 26.3238 12H12.7791C12.0774 12 11.4038 12.2756 10.9033 12.7675L9.41252 14.2325C8.91203 14.7244 8.23837 15 7.53665 15H3.67625C2.1982 15 1 16.1982 1 17.6762Z"
fill="#F5F5F5"
stroke="black"
stroke-width="0.892083"
/>
<path d="M6 6H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
<path d="M6 8H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
<path d="M6 10H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,17 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_205_498)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#e5efe8"/>
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
<path d="M15.7661 32.1765L16.9463 25.8847C16.9619 25.8019 17.0341 25.7419 17.1183 25.7419H17.7667C17.8518 25.7419 17.9246 25.8032 17.9391 25.8871L19.0289 32.1808C19.0379 32.2323 19.0233 32.285 18.9893 32.3247L17.5726 33.9747C17.504 34.0545 17.3811 34.0563 17.3103 33.9784L15.8086 32.3265C15.7717 32.2859 15.7559 32.2304 15.7661 32.1765Z" fill="#70B62B"/>
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
<rect x="22.451" y="12.5238" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5238)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_205_498">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="31" height="25" viewBox="0 0 31 25" fill="none">
<path d="M1 3.33195V22.2722C1 22.8812 1.36501 23.4309 1.92631 23.6671C2.11218 23.7454 2.31181 23.7857 2.51347 23.7857H14.2872H21.3335H28.1001C28.2854 23.7857 28.4696 23.7573 28.6463 23.7015L28.7223 23.6775C29.4771 23.4392 29.9903 22.7391 29.9903 21.9476V3.33195C29.9903 2.33004 29.1781 1.51782 28.1761 1.51782H15.4951H2.81413C1.81221 1.51782 1 2.33004 1 3.33195Z" fill="#F5F5F5" stroke="black" stroke-width="0.907066"/>
<path d="M29.5785 20.5535L17.4516 8.17127C16.3816 7.07877 14.6218 7.08216 13.5561 8.17876L1.75937 20.3169C1.27243 20.8179 1.00003 21.4891 1.00003 22.1877V22.6527C1.00003 23.0415 1.1994 23.4031 1.52818 23.6107C1.70922 23.725 1.91896 23.7857 2.13308 23.7857H3.41589L8.16288 23.7857L21.7535 23.7857L28.3797 23.7857C30.0271 23.7857 30.4021 21.643 29.5785 20.5535Z" stroke="black" stroke-width="0.907066"/>
<path d="M1.63846 4.39857L14.8989 16.0094C15.2425 16.3102 15.7562 16.3087 16.098 16.0058L29.54 4.09441C29.8325 3.83517 30 3.46302 30 3.07214C30 2.7761 29.9038 2.48808 29.7259 2.25144L29.582 2.06001C29.33 1.72469 28.9826 1.47328 28.5853 1.33869L28.0101 1.14386C27.7289 1.0486 27.434 1 27.1371 1H22.841H16.0351H12.4121H8.78904H2.57812C1.03955 1 0.661144 2.87867 1.28989 3.99863C1.37708 4.15393 1.50446 4.28124 1.63846 4.39857Z" fill="#F5F5F5" stroke="black" stroke-width="0.907066"/>
<path d="M15.0422 6.5331C14.7955 6.5331 14.5681 6.62806 14.3593 6.81946C14.1506 7.01087 13.9925 7.28302 13.8867 7.63592C13.7815 7.98882 13.7279 8.3021 13.7279 8.575C13.7279 8.93912 13.8017 9.20828 13.9485 9.38024C14.0962 9.55221 14.2788 9.63894 14.4965 9.63894L14.668 10.3791C14.5397 10.4113 14.407 10.427 14.2691 10.427C13.7935 10.427 13.3991 10.2625 13.0852 9.93352C12.9876 9.83109 12.9048 9.71744 12.8377 9.59333C12.6894 9.32043 12.6148 8.99669 12.6148 8.62136C12.6148 7.94621 12.8042 7.33012 13.1821 6.77385C13.6406 6.09571 14.2274 5.75702 14.9438 5.75702C14.999 5.75702 15.0527 5.75926 15.1041 5.7645L15.0422 6.53236V6.5331Z" fill="#70B62B"/>
<path d="M17.5203 5.50581C17.0104 5.03029 16.2984 4.79253 15.3852 4.79253C14.6091 4.79253 13.9515 4.95179 13.4118 5.2688C12.872 5.58581 12.4649 6.03218 12.1913 6.60863C11.9177 7.18434 11.7813 7.78397 11.7813 8.40679C11.7813 8.89876 11.882 9.35558 12.0832 9.77652C12.1295 9.87372 12.1817 9.96868 12.2383 10.0621C12.5432 10.5593 12.9719 10.9212 13.5228 11.1493C14.0738 11.3773 14.7045 11.4917 15.415 11.4917C16.1255 11.4917 16.6883 11.396 17.1804 11.2053C17.6732 11.0147 18.0698 10.7276 18.371 10.3463H19.2857C18.9994 10.9294 18.5603 11.3855 17.9669 11.7167C17.2877 12.0958 16.4602 12.2857 15.4836 12.2857C14.5069 12.2857 13.7219 12.1257 13.0353 11.8057C12.3479 11.485 11.8372 11.0132 11.5017 10.3881C11.167 9.76232 11 9.08193 11 8.34697C11 7.53948 11.1901 6.78807 11.5711 6.09124C11.952 5.39516 12.4739 4.87179 13.1352 4.52262C13.7964 4.17496 14.5524 4 15.403 4C16.1247 4 16.7659 4.14131 17.328 4.42244C17.8894 4.70356 18.318 5.10431 18.614 5.62245C18.9107 6.14059 19.0591 6.70732 19.0591 7.32191C19.0591 8.05388 18.8339 8.71632 18.3844 9.30773C17.82 10.0547 17.0969 10.4277 16.2149 10.4277C15.9778 10.4277 15.7982 10.3866 15.6774 10.3029C15.5574 10.2199 15.4769 10.098 15.4373 9.93727C15.2047 10.1623 14.9475 10.3096 14.6672 10.3799L14.4957 9.6397C14.659 9.6397 14.8118 9.59932 14.955 9.51858C15.0638 9.46175 15.1712 9.37128 15.2771 9.24717C15.4291 9.07222 15.5596 8.81651 15.6707 8.48006C15.781 8.14435 15.8362 7.83183 15.8362 7.54098C15.8362 7.21724 15.7609 6.96751 15.611 6.79405C15.4604 6.61985 15.2711 6.53312 15.0422 6.53312L15.1041 5.76526C15.5328 5.80264 15.8563 5.99554 16.0733 6.34396L16.1806 5.86544H17.3146L16.666 8.95408C16.6264 9.14997 16.6056 9.27633 16.6056 9.33315C16.6056 9.40493 16.622 9.45876 16.6555 9.49465C16.6891 9.53054 16.7278 9.54848 16.7733 9.54848C16.9105 9.54848 17.0887 9.46549 17.3056 9.29876C17.5979 9.08044 17.8342 8.7881 18.0146 8.42024C18.195 8.05239 18.2852 7.67182 18.2852 7.27855C18.2852 6.61611 18.0608 6.0561 17.6135 5.59628C17.5837 5.56563 17.5524 5.53572 17.5203 5.50581Z" fill="#70B62B"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26" fill="none">
<g clip-path="url(#clip0_2948_1176)">
<path d="M0 23.2893L0 2.71C0 1.21331 1.21331 0 2.71 0L23.29 0C24.7867 0 26 1.21331 26 2.71V23.2893C26 24.7864 24.7864 26 23.2893 26H2.71067C1.21361 26 0 24.7864 0 23.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
<path d="M20.3343 9.82838L22.2668 11.6587M15 17L17.4497 16.8716L22.5941 11.4795C22.7228 11.3447 22.8249 11.1845 22.8945 11.0083C22.9642 10.8321 23 10.6432 23 10.4525C23 10.2617 22.9642 10.0729 22.8945 9.89664C22.8249 9.72042 22.7228 9.5603 22.5941 9.42542C22.4654 9.29055 22.3127 9.18356 22.1445 9.11056C21.9764 9.03757 21.7962 9 21.6142 9C21.4323 9 21.2521 9.03757 21.0839 9.11056C20.9158 9.18356 20.763 9.29055 20.6343 9.42542L15.4899 14.8175L15 17Z" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12H15" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
<path d="M3 16H13" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
<path d="M3 20H15" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
<path d="M0 6L0 2.71067C0 1.21361 1.21361 0 2.71067 0L23.2893 0C24.7864 0 26 1.21361 26 2.71067V6H0Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
</g>
<defs>
<clipPath id="clip0_2948_1176">
<rect width="26" height="26" rx="2" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,5 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4414 21.2559C14.3847 22.1215 14.1349 22.8742 13.7871 23.4141C13.3852 24.0377 12.9135 24.2998 12.499 24.2998C12.0847 24.2996 11.6136 24.0375 11.2119 23.4141C10.8642 22.8742 10.6133 22.1215 10.5566 21.2559C11.1641 21.2729 11.8114 21.2861 12.5 21.2861C13.1882 21.2861 13.8344 21.2729 14.4414 21.2559Z" fill="#F5F5F5" stroke="#808080" stroke-width="1.4"/>
<path d="M12.4297 0.700195C13.0843 0.700204 13.6259 1.23742 13.626 1.91406C13.626 2.59074 13.0844 3.12792 12.4297 3.12793C11.775 3.12793 11.2334 2.59075 11.2334 1.91406C11.2334 1.23741 11.775 0.700195 12.4297 0.700195Z" fill="white" stroke="#808080" stroke-width="1.4"/>
<path d="M12.4346 3.62988C13.0533 3.63005 13.3559 3.62244 13.873 3.70996C14.1827 3.76237 14.4159 3.83987 14.6787 3.94629L14.9551 4.0625C17.2877 5.0584 18.9277 7.39184 18.9277 10.1143V14.6738C18.9277 15.7754 19.438 16.8147 20.3086 17.4844C20.5835 17.6958 20.9084 17.9327 21.2842 18.1865C21.3461 18.2283 21.3876 18.3013 21.3877 18.3838V20.2969C21.3876 20.3982 21.3261 20.4829 21.2432 20.5166C20.4748 20.8286 18.2879 21.3262 12.4277 21.3262C6.93322 21.3262 4.66803 20.8889 3.77344 20.5771L3.6123 20.5166C3.52941 20.4829 3.46882 20.3982 3.46875 20.2969V18.3838C3.46882 18.322 3.4918 18.2657 3.5293 18.2236L3.57129 18.1865C3.94707 17.9327 4.27194 17.6958 4.54688 17.4844C5.41738 16.8149 5.9276 15.7764 5.92773 14.6748V10.1562C5.92773 6.92932 7.89268 4.10108 10.9541 3.71484C11.539 3.64107 11.8416 3.62973 12.4346 3.62988Z" fill="#F5F5F5" stroke="#808080" stroke-width="1.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4.06082 3.75646C4.2774 3.8327 4.50791 3.86117 4.73652 3.83993C4.96514 3.81868 5.18646 3.74823 5.38528 3.63339C5.58411 3.51856 5.75573 3.36207 5.88838 3.17465C6.02102 2.98724 6.11155 2.77335 6.15374 2.54766L6.4084 1.1789C7.4555 0.940367 8.54285 0.940367 9.58996 1.1789L9.8462 2.54766C9.8884 2.77335 9.97892 2.98724 10.1116 3.17465C10.2442 3.36207 10.4158 3.51856 10.6147 3.63339C10.8135 3.74823 11.0348 3.81868 11.2634 3.83993C11.492 3.86117 11.7226 3.8327 11.9391 3.75646L13.2514 3.2949C13.9818 4.08176 14.5261 5.02277 14.8438 6.04833L13.7854 6.95553C13.611 7.10495 13.4711 7.29031 13.3752 7.49889C13.2793 7.70748 13.2296 7.93435 13.2296 8.16394C13.2296 8.39353 13.2793 8.6204 13.3752 8.82898C13.4711 9.03757 13.611 9.22293 13.7854 9.37234L14.8438 10.2787C14.5261 11.3043 13.9818 12.2453 13.2514 13.0322L11.9383 12.5706C11.7218 12.4944 11.4912 12.4659 11.2626 12.4872C11.034 12.5084 10.8127 12.5789 10.6139 12.6937C10.415 12.8085 10.2434 12.965 10.1108 13.1524C9.97813 13.3398 9.8876 13.5537 9.84541 13.7794L9.59155 15.1482C8.54444 15.3867 7.4571 15.3867 6.40999 15.1482L6.15374 13.7794C6.11155 13.5537 6.02102 13.3398 5.88838 13.1524C5.75573 12.965 5.58411 12.8085 5.38528 12.6937C5.18646 12.5789 4.96514 12.5084 4.73652 12.4872C4.50791 12.4659 4.2774 12.4944 4.06082 12.5706L2.74856 13.0322C2.0181 12.2453 1.47389 11.3043 1.15619 10.2787L2.21459 9.37155C2.38877 9.22213 2.52858 9.03682 2.62443 8.82831C2.72028 8.6198 2.7699 8.39303 2.7699 8.16354C2.7699 7.93405 2.72028 7.70728 2.62443 7.49877C2.52858 7.29026 2.38877 7.10495 2.21459 6.95553L1.15619 6.04833C1.47366 5.02284 2.01759 4.08184 2.74777 3.2949L4.06082 3.75646ZM7.99997 5.77617C8.63314 5.77617 9.24038 6.0277 9.6881 6.47542C10.1358 6.92313 10.3873 7.53037 10.3873 8.16354C10.3873 8.79671 10.1358 9.40395 9.6881 9.85166C9.24038 10.2994 8.63314 10.5509 7.99997 10.5509C7.36681 10.5509 6.75957 10.2994 6.31185 9.85166C5.86413 9.40395 5.61261 8.79671 5.61261 8.16354C5.61261 7.53037 5.86413 6.92313 6.31185 6.47542C6.75957 6.0277 7.36681 5.77617 7.99997 5.77617Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,40 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4199_10908)">
<g filter="url(#filter0_di_4199_10908)">
<circle cx="17.5" cy="17.5" r="17.5" fill="#FDFDFD"/>
<circle cx="17.5" cy="17.5" r="17" stroke="url(#paint0_linear_4199_10908)" stroke-opacity="0.2"/>
</g>
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
<path d="M15.7661 32.1768L16.9463 25.8849C16.9619 25.8022 17.0341 25.7422 17.1183 25.7422H17.7667C17.8518 25.7422 17.9246 25.8034 17.9391 25.8873L19.0289 32.1811C19.0379 32.2325 19.0233 32.2853 18.9893 32.3249L17.5726 33.975C17.504 34.0548 17.3811 34.0565 17.3103 33.9787L15.8086 32.3268C15.7717 32.2862 15.7559 32.2307 15.7661 32.1768Z" fill="#70B62B"/>
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
<rect x="22.451" y="12.5248" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5248)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
</g>
<defs>
<filter id="filter0_di_4199_10908" x="-2" y="-1" width="39" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4199_10908"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4199_10908" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.25"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_4199_10908"/>
</filter>
<linearGradient id="paint0_linear_4199_10908" x1="1.09375" y1="1.59091" x2="35" y2="1.59091" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<clipPath id="clip0_4199_10908">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26" fill="none">
<g clip-path="url(#clip0_9701_40694)">
<rect width="26" height="26" rx="2" fill="white"></rect>
<path d="M0 23.2893L0 2.71C0 1.21331 1.21331 0 2.71 0L23.29 0C24.7867 0 26 1.21331 26 2.71V23.2893C26 24.7864 24.7864 26 23.2893 26H2.71067C1.21361 26 0 24.7864 0 23.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
<path d="M0 7.5L0 2.71067C0 1.21361 1.21361 0 2.71067 0L23.2893 0C24.7864 0 26 1.21361 26 2.71067V7.5H0Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
<circle cx="2.75" cy="3.75" r="0.75" fill="#70B62B"></circle>
<circle cx="4.95001" cy="3.75" r="0.75" fill="#70B62B"></circle>
<circle cx="7.15002" cy="3.75" r="0.75" fill="#70B62B"></circle>
<path d="M10 4H24" stroke="#70B62B" stroke-width="0.9" stroke-linecap="square"></path>
<rect x="2.45" y="10.45" width="9.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
<rect x="2.45" y="15.45" width="14.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
<rect x="7.45" y="20.45" width="16.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
</g>
<defs>
<clipPath id="clip0_9701_40694">
<rect width="26" height="26" rx="2" fill="white"></rect>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,5 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 7.5H19.5341" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 12.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 17.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 432 B

@@ -0,0 +1,5 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.0755 4.16406L3.39658 4.16406C2.71318 4.16406 2.15918 4.71777 2.15918 5.4008L2.15918 19.0967C2.15918 19.7797 2.71318 20.3335 3.39658 20.3335H21.0755C21.7589 20.3335 22.3129 19.7797 22.3129 19.0967V5.4008C22.3129 4.71777 21.7589 4.16406 21.0755 4.16406Z" stroke="#808080" stroke-width="1.15" stroke-miterlimit="10"/>
<path d="M9.10286 12.375L2.60676 18.15C1.74915 18.9153 2.28822 20.3357 3.43986 20.3357H21.033C22.1846 20.3357 22.7237 18.9153 21.8661 18.15L15.37 12.375" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.6101 14.5783L21.8661 6.34978C22.7237 5.58447 22.1846 4.16406 21.033 4.16406L3.43986 4.16406C2.28822 4.16406 1.74915 5.58447 2.60676 6.34978L11.8628 14.5783C12.0772 14.7681 12.4018 14.7681 12.6101 14.5783Z" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 976 B

@@ -0,0 +1,14 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_mobile_spreed)">
<path d="M11.1875 19.743V18.7995C11.1875 18.3126 11.5822 17.918 12.069 17.918H13.3336C13.8204 17.918 14.2151 18.3126 14.2151 18.7995V19.743C14.2151 20.2298 13.8204 20.6245 13.3336 20.6245H12.069C11.5822 20.6245 11.1875 20.2298 11.1875 19.743Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
<path d="M7.09047 15.9141C8.56573 17.3894 10.4773 18.1623 12.4098 18.2329C14.5365 18.3106 16.6884 17.5376 18.3119 15.9141C21.4106 12.8154 21.4106 7.7915 18.3119 4.69274C15.2131 1.59398 10.1892 1.59398 7.09047 4.69274C3.99171 7.7915 3.99171 12.8154 7.09047 15.9141Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.1764 12.8321C10.8403 13.496 11.7005 13.8438 12.5701 13.8755C13.527 13.9105 14.4954 13.5627 15.2259 12.8321C16.6203 11.4377 16.6203 9.17699 15.2259 7.78258C13.8315 6.38816 11.5708 6.38816 10.1764 7.78258C8.782 9.17699 8.782 11.4377 10.1764 12.8321Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.1779 5.30035C12.3155 5.43795 12.4938 5.51003 12.674 5.51662C12.8723 5.52386 13.073 5.45177 13.2245 5.30035C13.5135 5.01134 13.5135 4.54278 13.2245 4.25376C12.9355 3.96475 12.4669 3.96475 12.1779 4.25376C11.8889 4.54278 11.8889 5.01134 12.1779 5.30035Z" fill="#808080"/>
<path d="M7.47754 22.1609V21.2174C7.47754 20.7306 7.8722 20.3359 8.35905 20.3359H17.0433C17.5302 20.3359 17.9248 20.7306 17.9248 21.2174V22.1609C17.9248 22.6478 17.5302 23.0425 17.0433 23.0425H8.35905C7.87221 23.0425 7.47754 22.6478 7.47754 22.1609Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
</g>
<defs>
<clipPath id="clip0_mobile_spreed">
<rect width="22.0417" height="23" fill="white" transform="translate(1.47949 1)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,12 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 20.0191V4.98038C3 3.88665 3.88665 3 4.98038 3H20.0196C21.1133 3 22 3.88665 22 4.98038V20.0191C22 21.1131 21.1131 22 20.0191 22H4.98087C3.88687 22 3 21.1131 3 20.0191Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
<path d="M3 8.48077V4.98087C3 3.88687 3.88687 3 4.98087 3H20.0191C21.1131 3 22 3.88687 22 4.98087V8.48077H3Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
<mask id="path-3-inside-1_mobile_tasks" fill="white">
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769"/>
</mask>
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-3-inside-1_mobile_tasks)"/>
<mask id="path-4-inside-2_mobile_tasks" fill="white">
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769"/>
</mask>
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-4-inside-2_mobile_tasks)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,3 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.9847 20L7.43923 13.1999C7.01817 12.8204 7.01861 12.1598 7.44019 11.7809L14.9847 5" stroke="#808080" stroke-width="1.73077" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 264 B

@@ -9,22 +9,25 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -35,8 +38,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -47,15 +52,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
// Живой forbion (_header.css .app-menu--mobile): нижний отступ 90 под плавающую панель,
// иконки 62, сетка 3 колонки с gap 8, БЕЗ скругления верха и БЕЗ ручки-хендла,
// высота — по контенту (почти весь экран), фон #FBFBFB.
private val BottomBarReserve: Dp = 90.dp
private val MenuIconSize = 62.dp
private val MenuGridGap = 20.dp
private val MenuGridGap = 8.dp
data class F7AppMenuItem(
val label: String,
val iconUrl: String,
val selected: Boolean,
val externalUrl: String? = null,
// Локальная иконка вместо серверной (для нативных пунктов — Уведомления/Настройки),
// рисуется в круглом бейдже с зелёной обводкой под стиль glass-иконок.
val localIcon: ImageVector? = null,
)
@Composable
@@ -78,58 +89,81 @@ fun F7AppMenuSheet(
}
val base = serverUrl.trimEnd('/')
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
animationSpec = tween(350),
initialOffsetY = { it },
),
exit = fadeOut(tween(200)) + slideOutVertically(
animationSpec = tween(300),
targetOffsetY = { it },
),
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = BottomBarReserve)
.navigationBarsPadding()
.background(F7Colors.Background)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
Box(modifier = modifier.fillMaxSize()) {
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
modifier = Modifier.fillMaxSize(),
) {
F7AppMenuSearchField(
serverUrl = base,
value = searchQuery,
onValueChange = { searchQuery = it },
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.18f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
)
}
// Сам лист — выезжает снизу вверх (translateY 100%→0), как в живом forbion:
// без скругления верха и без ручки, высота по контенту до почти всего экрана.
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
animationSpec = tween(350),
initialOffsetY = { it },
),
exit = fadeOut(tween(200)) + slideOutVertically(
animationSpec = tween(300),
targetOffsetY = { it },
),
modifier = Modifier.align(Alignment.BottomCenter),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
contentPadding = PaddingValues(horizontal = 2.dp),
modifier = Modifier.fillMaxSize(),
.heightIn(max = 640.dp)
.background(F7Colors.Background)
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = BottomBarReserve)
.verticalScroll(rememberScrollState()),
) {
itemsIndexed(
items = filteredItems,
key = { index, item -> "${item.label}-$index" },
) { index, item ->
val originalIndex = items.indexOf(item)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter,
) {
F7AppMenuGridItem(
item = item,
onClick = {
if (originalIndex >= 0) {
onItemClick(originalIndex)
F7AppMenuSearchField(
serverUrl = base,
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
)
// Сетка 3 колонки, gap 8 (живой forbion). Не-ленивая — лист сам в verticalScroll.
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
) {
filteredItems.chunked(3).forEach { rowItems ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
) {
rowItems.forEach { item ->
val originalIndex = items.indexOf(item)
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.TopCenter,
) {
F7AppMenuGridItem(
item = item,
onClick = { if (originalIndex >= 0) onItemClick(originalIndex) },
)
}
},
)
}
// добить ряд пустыми ячейками для выравнивания по левому краю
repeat(3 - rowItems.size) { Box(modifier = Modifier.weight(1f)) {} }
}
}
}
}
@@ -195,17 +229,42 @@ private fun F7AppMenuGridItem(
) {
Column(
modifier = Modifier
.width(MenuIconSize)
.clickable(onClick = onClick),
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize),
contentScale = ContentScale.Fit,
)
// Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
// как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
val frameShape = CircleShape
Box(
modifier = Modifier
.size(MenuIconSize)
.shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
.clip(frameShape)
.background(Color.White)
.border(1.dp, Color(0xFFECECEC), frameShape),
contentAlignment = Alignment.Center,
) {
val localIcon = item.localIcon
if (localIcon != null) {
Icon(
localIcon,
contentDescription = item.label,
tint = F7Colors.Primary,
modifier = Modifier.size(MenuIconSize * 0.5f),
)
} else {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize * 0.56f),
contentScale = ContentScale.Fit,
)
}
}
Text(
text = item.label,
style = MaterialTheme.typography.labelLarge.copy(
@@ -3,10 +3,9 @@ package ru.forbion.f7cloud.core.designsystem
enum class F7BottomBarSlot {
Chats,
NavBack,
Create,
Profile,
Notifications,
Settings,
SectionMail,
SectionTasks,
SectionConferences,
Menu,
}
@@ -16,72 +15,20 @@ data class F7BottomBarConfig(
val buttonCount: Int get() = slots.size
companion object {
// Нижняя панель 1:1 с мобильным сайтом forbion (layout.user.php):
// «назад», затем ярлыки-разделы Почта · Задачи · Конференции, справа — меню.
@Suppress("UNUSED_PARAMETER")
fun forContext(
tabKey: String,
talkInRoom: Boolean,
): F7BottomBarConfig = when (tabKey) {
"Talk" -> if (talkInRoom) {
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
} else {
F7BottomBarConfig(
listOf(
F7BottomBarSlot.Chats,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
}
"Files" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Create,
F7BottomBarSlot.Settings,
F7BottomBarSlot.Menu,
),
)
"Contacts" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Tasks" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Support" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Mail", "Calendar" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Settings,
F7BottomBarSlot.Menu,
),
)
else -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
}
): F7BottomBarConfig = F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.SectionMail,
F7BottomBarSlot.SectionTasks,
F7BottomBarSlot.SectionConferences,
F7BottomBarSlot.Menu,
),
)
}
}
@@ -3,10 +3,25 @@ package ru.forbion.f7cloud.core.designsystem
import androidx.compose.ui.graphics.Color
/**
* Palette from themes/forbion (mobile + f7support), light theme.
* Palette from themes/forbion. Base-токены — из Figma «Брендбук» (палитра владельца, 2026-07-08),
* семантические — поверх Base. Светлая тема.
*/
object F7Colors {
val Primary = Color(0xFF70B62B)
// --- Base palette (Figma «Брендбук») — точные значения ---
val Black = Color(0xFF151515) // Base/Black — основной текст
val Black2 = Color(0xFF1F1F1F) // Base/Black 2
val Grey1 = Color(0xFF808080) // Base/Grey 1 — вторичный текст
val Grey2 = Color(0xFFF5F5F5) // Base/Grey 2 — приглушённый фон
val Grey3 = Color(0xFFE6E6E6) // Base/Grey 3 — границы
val Green = Color(0xFF70B62B) // Base/Green — primary
val Green30 = Color(0x4D70B62B) // Base/Green 30%
val Green10 = Color(0x1A70B62B) // Base/Green 10%
val Yellow = Color(0xFFF6E120) // Base/Yellow (тег «Позже» и т.п.)
val Purple = Color(0xFF9747FF) // Base/Purple (тег «Личное» и т.п.)
val Red = Color(0xFFFF7A66) // Base/Red — ошибки/удаление (дизайн!)
val Red10 = Color(0x1AFF7A66) // Base/Red 10% — фон ошибки
val Primary = Green
val PrimaryHover = Color(0xFF6FAF2E)
val PrimaryDark = Color(0xFF5E922B)
val PrimaryLight = Color(0xFFECF9DE)
@@ -15,19 +30,22 @@ object F7Colors {
val Background = Color(0xFFFBFBFB)
val Surface = Color(0xFFFFFFFF)
val SurfaceMuted = Color(0xFFF5F5F5)
val SurfaceMuted = Grey2
val TextPrimary = Color(0xFF151515)
val TextSecondary = Color(0xFF808080)
val TextPrimary = Black
val TextSecondary = Grey1
val TextMuted = Color(0xFF8C8C8C)
val TextOnPrimary = Color(0xFFFFFFFF)
val Border = Color(0xFFE6E6E6)
val Border = Grey3
val BorderLight = Color(0xFFE0E0E0)
val SecondaryButtonBg = Color(0xFFFDFDFD)
val SecondaryButtonBorder = Color(0xFFE6E6E6)
val SecondaryButtonBorder = Grey3
// Семантика ошибок — по боевой теме forbion (_base.css: --color-element-error /
// --color-text-error / --color-error); базовый Red #FF7A66 из брендбука остаётся для тегов.
val Error = Color(0xFFD74642)
val ErrorText = Color(0xFFD42722)
val ErrorBg = Color(0xFFFFE2E2)
val StatusNew = Color(0xFF2B9AB6)
@@ -16,8 +16,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -43,11 +46,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
/**
@@ -69,6 +75,46 @@ fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () ->
}
}
/**
* Единый экран ошибки (по центру): иконка + сообщение + кнопка «Повторить».
* Используется везде, где загрузка упала и данных нет — вместо россыпи красного текста.
*/
@Composable
fun F7ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(F7Colors.Error.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Text("!", color = F7Colors.Error, fontSize = 30.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(12.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
textAlign = TextAlign.Center,
)
if (onRetry != null) {
Spacer(Modifier.height(16.dp))
F7SecondaryButton(text = "Повторить", onClick = onRetry)
}
}
}
@Composable
fun F7ModuleScreen(
title: String? = null,
@@ -76,6 +122,9 @@ fun F7ModuleScreen(
loading: Boolean = false,
error: String? = null,
onRefresh: (() -> Unit)? = null,
// Действие «Повторить» в экране ошибки; по умолчанию — как onRefresh.
// Позволяет дать retry, не показывая хедер-кнопку ↻ (когда onRefresh не задан).
onErrorRetry: (() -> Unit)? = onRefresh,
headerActions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
@@ -126,14 +175,22 @@ fun F7ModuleScreen(
CircularProgressIndicator(color = F7Colors.Primary)
}
if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
// Ошибка без данных → центрированный экран с «Повторить»
F7ErrorState(
message = error,
onRetry = onErrorRetry,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
content = content,
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
content = content,
)
}
}
@@ -570,7 +627,10 @@ fun F7AppScaffold(
content(
Modifier
.fillMaxSize()
.f7SafeTopInsets(),
// Отступы под статус-бар (сверху) И системную навигацию (снизу),
// иначе на edge-to-edge (targetSdk 36) контент залезает под панели.
.f7SafeTopInsets()
.f7SafeBottomInsets(),
)
}
Box(
@@ -0,0 +1,53 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Зелёная круглая кнопка «+» — единый элемент создания в шапках экранов
* (Почта/Файлы/Контакты/…). Градиент Primary, как у других зелёных кнопок.
*/
@Composable
fun F7CreateButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 44.dp,
) {
Box(
modifier = modifier
.size(size)
.shadow(3.dp, CircleShape, spotColor = F7Colors.Primary.copy(alpha = 0.25f))
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
),
)
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
text = "+",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Light,
)
}
}
@@ -9,7 +9,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -21,29 +22,37 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
data class F7BottomBarActions(
val onChatsClick: () -> Unit = {},
val onNavBackClick: () -> Unit = {},
val onCreateClick: () -> Unit = {},
val onProfileClick: () -> Unit = {},
val onNotificationsClick: () -> Unit = {},
val onSettingsClick: () -> Unit = {},
val onMailClick: () -> Unit = {},
val onTasksClick: () -> Unit = {},
val onConferencesClick: () -> Unit = {},
val onMenuClick: () -> Unit = {},
)
// Иконки нижней панели — ЛОКАЛЬНЫЕ mobile-*-icon из живой темы (assets/menu/), 1:1 с сайтом.
private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
// Живой forbion (_header.css .header__mobile-bottom): пилюля 328×61, паддинг 3,
// фон #F5F5F5; кнопки 55, фон #FBFBFB, рамка #E6E6E6; активная кнопка-раздел и открытый
// бургер — полупрозрачный зелёный градиент 104°.
private val BottomBarWidth = 328.dp
private val BottomBarHeight = 61.dp
private val BottomBarButtonSize = 55.dp
private val BottomBarIconSize = 24.dp
private val BottomBarGap = 8.dp
private val BottomBarOuterPaddingH = 6.dp
private val BottomBarOuterPaddingV = 6.dp
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
private val BottomBarIconSize = 25.dp
private val BottomBarSectionIconSize = 25.dp
private val BottomBarBackIconSize = 18.dp
private val BottomBarOuterPadding = 3.dp
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
private val BottomBarBorderBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
)
private val BottomBarHighlightBrush = Brush.linearGradient(
private val BottomBarButtonBg = Color(0xFFFBFBFB)
private val BottomBarButtonBorder = Color(0xFFE6E6E6)
private val BottomBarActiveBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
)
@@ -53,6 +62,7 @@ fun F7MobileBottomBar(
userId: String,
config: F7BottomBarConfig,
actions: F7BottomBarActions,
activeTabKey: String = "",
menuOpen: Boolean = false,
chatsHighlighted: Boolean = false,
navBackHighlighted: Boolean = false,
@@ -60,10 +70,11 @@ fun F7MobileBottomBar(
modifier: Modifier = Modifier,
) {
val base = serverUrl.trimEnd('/')
val pillShape = RoundedCornerShape(percent = 50)
val pillShape = RoundedCornerShape(100.dp)
Row(
modifier = modifier
.wrapContentWidth()
.width(BottomBarWidth)
.height(BottomBarHeight)
.shadow(
elevation = 2.dp,
shape = pillShape,
@@ -71,11 +82,8 @@ fun F7MobileBottomBar(
)
.clip(pillShape)
.background(Color(0xFFF5F5F5))
.padding(
horizontal = BottomBarOuterPaddingH,
vertical = BottomBarOuterPaddingV,
),
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
.padding(BottomBarOuterPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
config.slots.forEach { slot ->
@@ -87,45 +95,42 @@ fun F7MobileBottomBar(
"$base/themes/forbion/images/header/chat-icon-gray.svg"
},
contentDescription = "Чаты",
highlighted = chatsHighlighted,
active = chatsHighlighted,
onClick = actions.onChatsClick,
)
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
contentDescription = "Папки",
highlighted = navBackHighlighted,
iconUrl = barIcon("sidebar-chevron-left.svg"),
contentDescription = "Назад",
iconSize = BottomBarBackIconSize,
iconRotation = if (navBackHighlighted) 180f else 0f,
onClick = actions.onNavBackClick,
)
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
contentDescription = "Создать",
onClick = actions.onCreateClick,
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
iconUrl = barIcon("mobile-mail-icon.svg"),
contentDescription = "Почта",
iconSize = BottomBarSectionIconSize,
active = activeTabKey.equals("mail", ignoreCase = true),
onClick = actions.onMailClick,
)
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
contentDescription = "Профиль",
onClick = actions.onProfileClick,
F7BottomBarSlot.SectionTasks -> F7BottomBarIconSlot(
iconUrl = barIcon("mobile-tasks-icon.svg"),
contentDescription = "Задачи",
iconSize = BottomBarSectionIconSize,
active = activeTabKey.equals("tasks", ignoreCase = true),
onClick = actions.onTasksClick,
)
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
contentDescription = "Уведомления",
showBadge = showNotificationBadge,
onClick = actions.onNotificationsClick,
)
F7BottomBarSlot.Settings -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/setting-menu-icon.svg",
contentDescription = "Настройки",
onClick = actions.onSettingsClick,
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
iconUrl = barIcon("mobile-spreed-icon.svg"),
contentDescription = "Конференции",
iconSize = BottomBarSectionIconSize,
active = activeTabKey.equals("talk", ignoreCase = true),
onClick = actions.onConferencesClick,
)
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
iconUrl = if (menuOpen) {
"$base/themes/forbion/images/header/menu-burger-green.svg"
} else {
"$base/themes/forbion/images/header/menu-burger-gray.svg"
},
iconUrl = barIcon("mobile-burger-icon.svg"),
contentDescription = "Меню",
highlighted = menuOpen,
active = menuOpen,
showBadge = showNotificationBadge,
onClick = actions.onMenuClick,
)
}
@@ -138,25 +143,27 @@ private fun F7BottomBarIconSlot(
iconUrl: String,
contentDescription: String,
onClick: () -> Unit,
highlighted: Boolean = false,
active: Boolean = false,
iconRotation: Float = 0f,
showBadge: Boolean = false,
iconSize: Dp = BottomBarIconSize,
) {
val bg = if (highlighted) BottomBarHighlightBrush else null
// Живой forbion: кнопка 55, фон #FBFBFB + рамка #E6E6E6; активная (текущий раздел /
// открытый бургер) — полупрозрачный зелёный градиент 104°.
Box(
modifier = Modifier
.size(BottomBarButtonSize)
.clip(BottomBarButtonShape)
.then(
if (bg != null) {
Modifier.background(bg, BottomBarButtonShape)
if (active) {
Modifier.background(BottomBarActiveBrush, BottomBarButtonShape)
} else {
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
Modifier.background(BottomBarButtonBg, BottomBarButtonShape)
},
)
.border(
width = 1.dp,
brush = BottomBarBorderBrush,
color = BottomBarButtonBorder,
shape = BottomBarButtonShape,
)
.clickable(
@@ -170,7 +177,7 @@ private fun F7BottomBarIconSlot(
model = iconUrl,
contentDescription = contentDescription,
modifier = Modifier
.size(BottomBarIconSize)
.size(iconSize)
.graphicsLayer { rotationZ = iconRotation },
contentScale = ContentScale.Fit,
)
@@ -0,0 +1,24 @@
package ru.forbion.f7cloud.core.designsystem
import android.app.Activity
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
/**
* Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
*/
@Composable
fun F7SecureScreen() {
val context = LocalContext.current
DisposableEffect(Unit) {
val window = (context as? Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
@@ -0,0 +1,31 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.ui.unit.dp
/**
* Токены отступов и радиусов (4dp-сетка). До этого отступы задавались числами по месту
* (аудит: ~1087 инлайновых .dp без токенов) — единая шкала для попиксельной вёрстки по Figma.
* Точные значения конкретных экранов сверяются с макетом; эти — базовая сетка.
*/
object F7Spacing {
val none = 0.dp
val xxs = 2.dp
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 20.dp
val xxl = 24.dp
val xxxl = 32.dp
/** Горизонтальные поля экрана (мобильный контент). */
val screenHorizontal = 16.dp
}
object F7Radius {
val none = 0.dp
val sm = 6.dp
val md = 10.dp
val lg = 14.dp
val pill = 999.dp
}
+6 -3
View File
@@ -23,7 +23,10 @@ android {
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
api 'com.squareup.okhttp3:okhttp:4.12.0'
implementation libs.coroutines.android
implementation libs.json
api libs.okhttp
testImplementation libs.junit
testImplementation libs.okhttp.mockwebserver
}
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:CalendarIcs.kt$CalendarIcs$(',')</ID>
<ID>ArgumentListWrapping:CalendarIcs.kt$CalendarIcs$(it.trim())</ID>
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>CyclomaticComplexMethod:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</ID>
<ID>ImportOrdering:CalendarIcsTest.kt$import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import org.junit.Test</ID>
<ID>LargeClass:CalDavClient.kt$CalDavClient</ID>
<ID>LoopWithTooManyJumpStatements:NotificationsRepository.kt$NotificationsRepository$for</ID>
<ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>MaximumLineLength:LoginFlowClient.kt$LoginFlowClient$ </ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun &lt;T&gt; parseCalendarData(xml: String, map: (String) -&gt; T?): List&lt;T&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List&lt;DavEvent&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List&lt;DavTask&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List&lt;DavCalendar&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List&lt;DavTrashEvent&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List&lt;Pair&lt;String, String&gt;&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String, bookUrl: String): List&lt;DavContact&gt;</ID>
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</ID>
<ID>NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val STATE_TOKEN_REGEX = Regex("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>SpacingBetweenDeclarationsWithAnnotations:UnauthorizedInterceptorTest.kt$UnauthorizedInterceptorTest$@After fun tearDown()</ID>
<ID>UnusedParameter:CalDavClient.kt$CalDavClient$calendar: DavCalendar</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun &lt;T&gt; parseCalendarData(xml: String, map: (String) -&gt; T?): List&lt;T&gt;</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun formatIcsLocal(zoned: ZonedDateTime): String</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun unescapeIcsText(text: String): String</ID>
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsDtEnd = Pattern.compile("DTEND[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsLocation = Pattern.compile("LOCATION:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
<ID>UnusedPrivateProperty:LoginFlowClient.kt$LoginFlowClient$val payload = extractPayload(qrData)</ID>
<ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID>
<ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID>
</CurrentIssues>
</SmellBaseline>
@@ -22,6 +22,13 @@ data class DavCalendar(
val href: String,
val displayName: String,
val color: String? = null,
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
val ctag: String = "",
// supported-calendar-component-set: поддерживает ли коллекция VEVENT. VTODO-only списки
// задач не принимают события → PUT VEVENT туда возвращает 403. По умолчанию true (если
// сервер не вернул свойство — не ломаем поведение).
val supportsEvents: Boolean = true,
)
data class DavEvent(
@@ -47,6 +54,7 @@ data class DavEvent(
val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
)
data class DavTrashEvent(
@@ -67,11 +75,15 @@ 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,
val calendarName: String,
val calendarHref: String,
/** UID родительской задачи (RELATED-TO;RELTYPE=PARENT) — для подзадач; пусто у корневых. */
val parentUid: String = "",
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
@@ -84,6 +96,11 @@ object CalDavClient {
private val icsDue = Pattern.compile("DUE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsStatus = Pattern.compile("STATUS:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsUid = Pattern.compile("UID:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.
private val icsRelatedTo = Pattern.compile(
"RELATED-TO(?:;[^:\\r\\n]*)?:([^\\r\\n]+)",
Pattern.CASE_INSENSITIVE,
)
private val icsDtStamp = Pattern.compile("DTSTAMP[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsLastModified = Pattern.compile("LAST-MODIFIED[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsPriority = Pattern.compile("PRIORITY:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
@@ -102,8 +119,8 @@ object CalDavClient {
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/></d:prop>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, baseUrl, depth = 1, body)
@@ -129,7 +146,15 @@ object CalDavClient {
calendar: DavCalendar,
rangeStart: Instant,
rangeEnd: Instant,
): List<DavEvent> {
): List<DavEvent> = parseEventsXml(queryEventsRawXml(client, calendar, rangeStart, rangeEnd), calendar)
/** Сырой REPORT-ответ (для кэширования на диске с ключом по CTag). */
fun queryEventsRawXml(
client: OkHttpClient,
calendar: DavCalendar,
rangeStart: Instant,
rangeEnd: Instant,
): String {
val startStr = formatCalDavTime(rangeStart)
val endStr = formatCalDavTime(rangeEnd)
val body = """
@@ -146,10 +171,13 @@ object CalDavClient {
</c:calendar-query>
""".trimIndent()
val href = calendar.href.trimEnd('/') + "/"
val xml = report(client, href, body)
return parseCalendarQueryResponses(xml, calendar)
return report(client, href, body)
}
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
parseCalendarQueryResponses(xml, calendar)
fun createEvent(
client: OkHttpClient,
calendar: DavCalendar,
@@ -189,7 +217,6 @@ object CalDavClient {
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV create event HTTP ${response.code}")
}
@@ -244,7 +271,6 @@ object CalDavClient {
.delete()
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete event HTTP ${response.code}")
}
@@ -278,13 +304,28 @@ object CalDavClient {
builder.header("If-None-Match", "*")
}
client.newCall(builder.build()).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV put event HTTP ${response.code}")
error("CalDAV put event HTTP ${response.code}${davErrorDetail(response)}")
}
}
}
/** Из тела sabre-ошибки достаём человекочитаемую причину (precondition/сообщение). */
private fun davErrorDetail(response: okhttp3.Response): String {
val body = runCatching { response.peekBody(4096).string() }.getOrNull().orEmpty()
if (body.isBlank()) return ""
// sabre: <s:message>…</s:message> и имя нарушенного precondition-элемента.
val message = Regex("<s:message>(.*?)</s:message>", RegexOption.DOT_MATCHES_ALL)
.find(body)?.groupValues?.getOrNull(1)?.trim()
val precondition = Regex("<cal:(supported-calendar-component-set|valid-calendar-object-resource|calendar-collection-location-ok)")
.find(body)?.groupValues?.getOrNull(1)
?: Regex("<d:(need-privileges)").find(body)?.groupValues?.getOrNull(1)
return buildString {
if (precondition != null) append(" ($precondition)")
if (!message.isNullOrBlank()) append(": ${message.take(180)}")
}
}
fun createCalendar(
client: OkHttpClient,
baseUrl: String,
@@ -314,7 +355,6 @@ object CalDavClient {
.method("MKCALENDAR", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV create calendar HTTP ${response.code}")
}
@@ -322,6 +362,122 @@ object CalDavClient {
return DavCalendar(href = url, displayName = displayName)
}
/** PROPPATCH: переименование и/или смена цвета календаря (null = не менять). */
fun updateCalendarProps(
client: OkHttpClient,
calendarHref: String,
displayName: String? = null,
color: String? = null,
) {
if (displayName == null && color == null) return
val props = buildString {
if (displayName != null) append("<d:displayname>${escapeXml(displayName)}</d:displayname>")
if (color != null) append("<ical:calendar-color>${escapeXml(color)}</ical:calendar-color>")
}
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:ical="http://apple.com/ns/ical/">
<d:set><d:prop>$props</d:prop></d:set>
</d:propertyupdate>
""".trimIndent()
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV proppatch HTTP ${response.code}")
}
}
fun deleteCalendar(client: OkHttpClient, calendarHref: String) {
val req = Request.Builder().url(calendarHref.trimEnd('/') + "/").delete().build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete calendar HTTP ${response.code}")
}
}
}
data class DavSharee(
val principal: String, // principal:principals/users/<id> или groups/<id>
val displayName: String,
val writable: Boolean,
)
/** Текущие получатели шаринга календаря (oc:invite). */
fun listCalendarShares(client: OkHttpClient, calendarHref: String): List<DavSharee> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop><oc:invite/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, calendarHref.trimEnd('/') + "/", depth = 0, body)
val out = mutableListOf<DavSharee>()
val parser = newParser(xml)
var principal = ""
var name = ""
var writable = false
var inUser = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"user" -> { inUser = true; principal = ""; name = ""; writable = false }
"href" -> if (inUser) principal = parser.readText().trim()
"common-name" -> if (inUser) name = parser.readText().trim()
"read-write" -> if (inUser) writable = true
}
XmlPullParser.END_TAG -> if (parser.localTag() == "user" && inUser) {
if (principal.isNotBlank()) {
out += DavSharee(
principal = principal,
displayName = name.ifBlank { principal.substringAfterLast('/') },
writable = writable,
)
}
inUser = false
}
}
parser.next()
}
return out
}
/** Пошарить календарь пользователю/группе (sabre sharing POST). */
fun shareCalendar(client: OkHttpClient, calendarHref: String, principal: String, writable: Boolean) {
val rw = if (writable) "<o:read-write/>" else ""
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:set>
<d:href>${escapeXml(principal)}</d:href>
<o:summary/>$rw
</o:set>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
fun unshareCalendar(client: OkHttpClient, calendarHref: String, principal: String) {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:remove><d:href>${escapeXml(principal)}</d:href></o:remove>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
private fun sharePost(client: OkHttpClient, calendarHref: String, body: String) {
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.post(body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV share HTTP ${response.code}")
}
}
fun createSubscription(
client: OkHttpClient,
baseUrl: String,
@@ -347,7 +503,6 @@ object CalDavClient {
.method("MKCOL", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV subscribe calendar HTTP ${response.code}")
}
@@ -405,7 +560,6 @@ object CalDavClient {
.header("Destination", destinationHref)
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV move HTTP ${response.code}")
}
@@ -578,28 +732,13 @@ object CalDavClient {
organizerName = parsed.organizerName,
alarms = parsed.alarms,
conferenceUri = parsed.conferenceUri,
attachments = parsed.attachments,
)
}
fun parseIcsInstant(raw: String): Instant? {
val value = raw.trim()
if (value.isBlank()) return null
return runCatching {
when {
value.contains('T') -> {
val clean = value.replace("Z", "", ignoreCase = true).take(15)
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
.toInstant(ZoneOffset.UTC)
}
value.length >= 8 -> {
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
}
else -> null
}
}.getOrNull()
}
// Единый парсер дат — в CalendarIcs (учитывает Z/TZID/floating). Раньше здесь была
// расходящаяся копия (трактовала всё как UTC) — источник рассинхрона календаря и задач.
fun parseIcsInstant(raw: String): Instant? = CalendarIcs.parseIcsInstant(raw)
private fun formatIcsUtc(instant: Instant): String = formatCalDavTime(instant)
@@ -642,6 +781,7 @@ object CalDavClient {
dueDate: LocalDate? = null,
priority: Int = 0,
description: String = "",
parentUid: String = "",
): DavTask {
val uid = "${UUID.randomUUID()}@f7cloud.mobile"
val ics = buildVTodoIcs(
@@ -652,6 +792,7 @@ object CalDavClient {
priority = priority,
percentComplete = 0,
description = description,
parentUid = parentUid,
)
val url = calendar.href.trimEnd('/') + "/$uid.ics"
val req = Request.Builder()
@@ -660,7 +801,6 @@ object CalDavClient {
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV create task HTTP ${response.code}")
}
@@ -679,6 +819,7 @@ object CalDavClient {
description = description,
calendarName = calendar.displayName,
calendarHref = calendar.href,
parentUid = parentUid,
)
}
@@ -700,6 +841,7 @@ object CalDavClient {
priority = priority,
percentComplete = percentComplete,
description = description,
parentUid = task.parentUid, // не терять связь с родителем при правке подзадачи
)
val req = Request.Builder()
.url(task.href)
@@ -707,7 +849,6 @@ object CalDavClient {
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204) {
error("CalDAV update task HTTP ${response.code}")
}
@@ -721,7 +862,6 @@ object CalDavClient {
.delete()
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete task HTTP ${response.code}")
}
@@ -781,7 +921,6 @@ object CalDavClient {
private fun execute(client: OkHttpClient, req: Request): String {
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val code = response.code
if (code !in 200..299 && code != 207) {
error("CalDAV error HTTP $code")
@@ -800,6 +939,99 @@ object CalDavClient {
.format(instant)
}
/** Занятый интервал участника (free/busy), millis UTC. */
data class BusyPeriod(val startEpochMilli: Long, val endEpochMilli: Long)
/**
* Запрос занятости участников через scheduling outbox (iTIP VFREEBUSY REQUEST).
* Возвращает email → занятые интервалы; участник без данных — пустой список.
*/
fun freeBusy(
client: OkHttpClient,
serverUrl: String,
userId: String,
organizerEmail: String,
attendeeEmails: List<String>,
rangeStart: Instant,
rangeEnd: Instant,
): Map<String, List<BusyPeriod>> {
if (organizerEmail.isBlank() || attendeeEmails.isEmpty()) return emptyMap()
val outbox = calendarsBase(serverUrl, userId).trimEnd('/') + "/outbox/"
val ics = buildString {
appendLine("BEGIN:VCALENDAR")
appendLine("VERSION:2.0")
appendLine("PRODID:-//F7cloud Mobile//EN")
appendLine("METHOD:REQUEST")
appendLine("BEGIN:VFREEBUSY")
appendLine("UID:${UUID.randomUUID()}@f7cloud.mobile")
appendLine("DTSTAMP:${formatCalDavTime(Instant.now())}")
appendLine("DTSTART:${formatCalDavTime(rangeStart)}")
appendLine("DTEND:${formatCalDavTime(rangeEnd)}")
appendLine("ORGANIZER:mailto:$organizerEmail")
attendeeEmails.forEach { appendLine("ATTENDEE:mailto:$it") }
appendLine("END:VFREEBUSY")
appendLine("END:VCALENDAR")
}
val req = Request.Builder()
.url(outbox)
.post(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build()
val xml = client.newCall(req).execute().use { response ->
if (!response.isSuccessful) error("CalDAV free-busy HTTP ${response.code}")
response.body?.string().orEmpty()
}
return parseFreeBusyResponse(xml)
}
private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>> {
val out = mutableMapOf<String, List<BusyPeriod>>()
val parser = newParser(xml)
var recipient = ""
var calendarData = StringBuilder()
var inCalendarData = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"response" -> { recipient = ""; calendarData = StringBuilder() }
"href" -> if (recipient.isBlank()) recipient = parser.readText().trim()
"calendar-data" -> { inCalendarData = true; calendarData = StringBuilder() }
}
XmlPullParser.TEXT -> if (inCalendarData) calendarData.append(parser.text)
XmlPullParser.END_TAG -> when (parser.localTag()) {
"calendar-data" -> inCalendarData = false
"response" -> if (recipient.isNotBlank()) {
val email = recipient.removePrefix("mailto:").trim()
out[email] = parseBusyPeriods(calendarData.toString())
}
}
}
parser.next()
}
return out
}
private val freeBusyLine = Pattern.compile("^FREEBUSY[^:]*:(.+)$", Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
private fun parseBusyPeriods(ics: String): List<BusyPeriod> {
val unfolded = ics.replace(Regex("\\r?\\n[ \\t]"), "")
val out = mutableListOf<BusyPeriod>()
val m = freeBusyLine.matcher(unfolded)
while (m.find()) {
m.group(1).orEmpty().split(',').forEach { period ->
val parts = period.trim().split('/')
if (parts.size != 2) return@forEach
val start = CalendarIcs.parseIcsInstant(parts[0]) ?: return@forEach
val end = if (parts[1].startsWith("P", ignoreCase = true)) {
runCatching { start.plus(java.time.Duration.parse(parts[1])) }.getOrNull() ?: return@forEach
} else {
CalendarIcs.parseIcsInstant(parts[1]) ?: return@forEach
}
out += BusyPeriod(start.toEpochMilli(), end.toEpochMilli())
}
}
return out.sortedBy { it.startEpochMilli }
}
private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> {
val parser = newParser(xml)
val out = mutableListOf<DavCalendar>()
@@ -808,7 +1040,9 @@ object CalDavClient {
var href = ""
var displayName = ""
var calendarColor: String? = null
var ctag = ""
var isCollection = false
var comps = mutableSetOf<String>()
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
@@ -817,11 +1051,17 @@ object CalDavClient {
href = ""
displayName = ""
calendarColor = null
ctag = ""
isCollection = false
comps = mutableSetOf()
}
"collection" -> if (inResponse) isCollection = true
"comp" -> if (inResponse) {
parser.getAttributeValue(null, "name")?.trim()?.uppercase()?.let { comps += it }
}
"displayname" -> if (inResponse) displayName = parser.readText().trim()
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
"getctag" -> if (inResponse) ctag = parser.readText().trim()
"href" -> if (inResponse) href = parser.readText().trim()
}
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
@@ -835,7 +1075,15 @@ object CalDavClient {
val name = displayName.ifBlank {
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
}
out += DavCalendar(href = full, displayName = name, color = calendarColor)
// Свойство не вернулось (comps пуст) → считаем событийным (не ломаем).
val supportsEvents = comps.isEmpty() || "VEVENT" in comps
out += DavCalendar(
href = full,
displayName = name,
color = calendarColor,
ctag = ctag,
supportsEvents = supportsEvents,
)
}
}
inResponse = false
@@ -942,11 +1190,13 @@ 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),
calendarName = calendarName,
calendarHref = calendarHref,
parentUid = extractIcs(icsRelatedTo, ics).trim(),
)
}
@@ -958,6 +1208,7 @@ object CalDavClient {
priority: Int,
percentComplete: Int,
description: String,
parentUid: String = "",
): String {
val now = Instant.now()
return buildString {
@@ -985,6 +1236,9 @@ object CalDavClient {
if (description.isNotBlank()) {
appendLine("DESCRIPTION:${escapeIcsText(description)}")
}
if (parentUid.isNotBlank()) {
appendLine("RELATED-TO;RELTYPE=PARENT:${escapeIcsText(parentUid)}")
}
appendLine("END:VTODO")
appendLine("END:VCALENDAR")
}
@@ -23,6 +23,12 @@ data class CalendarAlarmData(
val action: String = "DISPLAY",
)
data class CalendarAttachmentData(
val url: String,
val fileName: String = "",
val mimeType: String = "",
)
data class CalendarEventData(
val uid: String,
val summary: String,
@@ -40,11 +46,15 @@ data class CalendarEventData(
val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
)
object CalendarIcs {
private val veventBlock = Pattern.compile("BEGIN:VEVENT([\\s\\S]*?)END:VEVENT", Pattern.CASE_INSENSITIVE)
private val linePattern = Pattern.compile("^([A-Z0-9-]+)(?:;[^:]*)?:(.*)$", Pattern.MULTILINE)
/** Одно ICS-свойство: имя, параметры (TZID/CN/PARTSTAT…) и значение. Параметры НЕ теряем —
* раньше их срезал regex, из-за чего пропадал TZID и схлопывались ATTENDEE. */
private data class IcsProp(val name: String, val params: Map<String, String>, val value: String)
fun parseAll(ics: String): List<CalendarEventData> {
val unfolded = unfold(ics)
@@ -70,23 +80,22 @@ object CalendarIcs {
}
private fun parseVEventBlock(block: String): CalendarEventData? {
val lines = parseLines(block)
val props = parseProps(block)
// первое значение на имя — для простых одиночных свойств (SUMMARY/UID/…)
val lines = props.associate { it.name to it.value }
val uid = lines["UID"]?.trim().orEmpty()
if (uid.isBlank()) return null
val dtStartRaw = lines["DTSTART"].orEmpty()
val start = parseIcsInstant(dtStartRaw) ?: return null
val allDay = !dtStartRaw.contains('T')
val endRaw = lines["DTEND"]
val end = if (endRaw != null) {
parseIcsInstant(endRaw) ?: start.plusSeconds(if (allDay) 86400 else 3600)
} else {
start.plusSeconds(if (allDay) 86400 else 3600)
}
val attendees = lines.entries
.filter { it.key.startsWith("ATTENDEE") }
.mapNotNull { parseAttendeeLine(it.key, it.value) }
val organizer = lines["ORGANIZER"].orEmpty()
val (orgEmail, orgName) = parseOrganizer(organizer)
val dtStart = props.firstOrNull { it.name == "DTSTART" } ?: return null
val start = parseIcsInstant(dtStart.value, dtStart.params["TZID"]) ?: return null
val allDay = dtStart.params["VALUE"].equals("DATE", ignoreCase = true) || !dtStart.value.contains('T')
val dtEnd = props.firstOrNull { it.name == "DTEND" }
val end = dtEnd?.let { parseIcsInstant(it.value, it.params["TZID"]) }
?: start.plusSeconds(if (allDay) 86400 else 3600)
// каждый ATTENDEE — со своими параметрами (CN/PARTSTAT/ROLE), не схлопываем
val attendees = props.filter { it.name == "ATTENDEE" }.mapNotNull { parseAttendee(it) }
val organizerProp = props.firstOrNull { it.name == "ORGANIZER" }
val orgEmail = organizerProp?.value?.substringAfter("mailto:", organizerProp.value)?.trim().orEmpty()
val orgName = organizerProp?.params?.get("CN")?.let(::unescape).orEmpty()
val alarms = parseAlarms(block)
val conference = lines.entries
.firstOrNull { it.key.startsWith("CONFERENCE") }
@@ -97,6 +106,13 @@ object CalendarIcs {
val talkUrl = conference.ifBlank {
if (location.contains("/call/")) location else ""
}
val attachments = props.filter { it.name == "ATTACH" && it.value.isNotBlank() }.map {
CalendarAttachmentData(
url = it.value.trim(),
fileName = it.params["FILENAME"]?.let(::unescape).orEmpty(),
mimeType = it.params["FMTTYPE"].orEmpty(),
)
}
return CalendarEventData(
uid = uid,
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
@@ -114,6 +130,7 @@ object CalendarIcs {
organizerName = orgName,
alarms = alarms,
conferenceUri = talkUrl,
attachments = attachments,
)
}
@@ -161,6 +178,13 @@ object CalendarIcs {
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
if (data.location.isBlank()) appendLine("LOCATION:$talk")
}
data.attachments.forEach { att ->
if (att.url.isNotBlank()) {
val fmt = if (att.mimeType.isNotBlank()) ";FMTTYPE=${att.mimeType}" else ""
val fname = if (att.fileName.isNotBlank()) ";FILENAME=${escape(att.fileName)}" else ""
appendLine("ATTACH$fmt$fname:${att.url}")
}
}
data.alarms.forEach { alarm ->
appendLine("BEGIN:VALARM")
appendLine("ACTION:${alarm.action}")
@@ -173,52 +197,71 @@ object CalendarIcs {
fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile"
fun parseIcsInstant(raw: String): Instant? {
/**
* ICS date-time → Instant с учётом зоны:
* - суффикс `Z` → UTC (раньше срезался и трактовался как локальное — баг);
* - параметр TZID → указанная зона;
* - иначе (floating) → локальная зона устройства;
* - только дата (VALUE=DATE, 8 цифр) → начало дня в зоне (TZID или локальной).
*/
fun parseIcsInstant(raw: String, tzId: String? = null): Instant? {
val value = raw.trim()
if (value.isBlank()) return null
val paramZone = tzId?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return runCatching {
when {
value.contains('T') -> {
val clean = value.replace("Z", "", ignoreCase = true).take(15)
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
.atZone(ZoneId.systemDefault()).toInstant()
val hasZ = value.endsWith("Z", ignoreCase = true)
val clean = value.trimEnd('Z', 'z').take(15)
val ldt = LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
val zone = when {
hasZ -> ZoneOffset.UTC
else -> paramZone ?: ZoneId.systemDefault()
}
ldt.atZone(zone).toInstant()
}
value.length >= 8 -> {
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(ZoneId.systemDefault()).toInstant()
.atStartOfDay(paramZone ?: ZoneId.systemDefault()).toInstant()
}
else -> null
}
}.getOrNull()
}
private fun parseLines(block: String): Map<String, String> {
val map = mutableMapOf<String, String>()
unfold(block).lineSequence().forEach { line ->
val m = linePattern.matcher(line.trim())
if (m.find()) {
val key = m.group(1)?.uppercase().orEmpty()
val value = m.group(2).orEmpty()
map[key] = if (map.containsKey(key)) "${map[key]}\n$value" else value
}
/** Разбор ICS-строк в свойства с параметрами. Сворачивание строк уже снято в [unfold]. */
private fun parseProps(block: String): List<IcsProp> {
val out = mutableListOf<IcsProp>()
unfold(block).lineSequence().forEach { raw ->
val line = raw.trim()
val colon = line.indexOf(':')
if (colon <= 0) return@forEach
val head = line.substring(0, colon) // NAME;PARAM=VAL;PARAM2=VAL
val value = line.substring(colon + 1)
val parts = head.split(';')
val name = parts[0].uppercase()
val params = parts.drop(1).mapNotNull { p ->
val eq = p.indexOf('=')
if (eq <= 0) null else p.substring(0, eq).uppercase() to p.substring(eq + 1).trim('"')
}.toMap()
out += IcsProp(name, params, value)
}
return map
return out
}
private fun parseAttendeeLine(key: String, value: String): CalendarAttendeeData? {
val email = value.substringAfter("mailto:", value).trim()
private fun parseLines(block: String): Map<String, String> =
parseProps(block).associate { it.name to it.value }
private fun parseAttendee(prop: IcsProp): CalendarAttendeeData? {
val email = prop.value.substringAfter("mailto:", prop.value).trim()
if (email.isBlank()) return null
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1)?.let(::unescape)
val partStat = Regex("PARTSTAT=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "NEEDS-ACTION"
val role = Regex("ROLE=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "REQ-PARTICIPANT"
val rsvp = !key.contains("RSVP=FALSE", ignoreCase = true)
return CalendarAttendeeData(email = email, displayName = cn.orEmpty(), partStat = partStat, role = role, rsvp = rsvp)
}
private fun parseOrganizer(value: String): Pair<String, String> {
val email = value.substringAfter("mailto:", value).trim()
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(value)?.groupValues?.get(1)?.let(::unescape).orEmpty()
return email to cn
return CalendarAttendeeData(
email = email,
displayName = prop.params["CN"]?.let(::unescape).orEmpty(),
partStat = prop.params["PARTSTAT"] ?: "NEEDS-ACTION",
role = prop.params["ROLE"] ?: "REQ-PARTICIPANT",
rsvp = !prop.params["RSVP"].equals("FALSE", ignoreCase = true),
)
}
private fun parseAlarms(block: String): List<CalendarAlarmData> {
@@ -265,8 +308,29 @@ object CalendarIcs {
private fun escape(text: String): String =
text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
private fun unescape(text: String): String =
text.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
/** Single-pass: последовательные replace ломались на экранированном бэкслеше (`\\n` → перенос). */
private fun unescape(text: String): String {
if (text.indexOf('\\') < 0) return text
val sb = StringBuilder(text.length)
var i = 0
while (i < text.length) {
val c = text[i]
if (c == '\\' && i + 1 < text.length) {
when (val n = text[i + 1]) {
'n', 'N' -> sb.append('\n')
',' -> sb.append(',')
';' -> sb.append(';')
'\\' -> sb.append('\\')
else -> sb.append(n)
}
i += 2
} else {
sb.append(c)
i++
}
}
return sb.toString()
}
private fun formatUtc(instant: Instant): String =
instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
@@ -26,6 +26,35 @@ data class DavContact(
val birthday: String = "",
val emails: String = "",
val phones: String = "",
// Ниже — для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard.
// Заполняются только чтением из книги (listContacts); create возвращает пустыми — ок.
val href: String = "",
val etag: String = "",
val raw: String = "",
)
/** Типизированное значение (EMAIL/TEL) с меткой TYPE (HOME/WORK/CELL/…). */
data class VCardTypedValue(
val value: String,
val type: String = "",
)
/**
* Управляемые поля контакта, которые правит UI. Всё, чего здесь нет
* (PHOTO, X-*, UID, кастомные свойства), сохраняется из сырого vCard как есть.
*/
data class VCardFields(
val displayName: String,
val firstName: String = "",
val lastName: String = "",
val emails: List<VCardTypedValue> = emptyList(),
val phones: List<VCardTypedValue> = emptyList(),
val organization: String = "",
val title: String = "",
val address: String = "",
val website: String = "",
val birthday: String = "",
val note: String = "",
)
object CardDavClient {
@@ -69,6 +98,53 @@ object CardDavClient {
return out.distinctBy { "${it.uid}|${it.email}" }
}
/**
* Дешёвая «подпись» состояния адресных книг — CTag коллекций (расширение
* CalendarServer, поддерживается Nextcloud), с фолбэком на sync-token.
* Один PROPFIND depth:1 без address-data. Если подпись не изменилась с прошлой
* синхронизации — контакты качать не нужно. null при ошибке/неподдержке → полный sync.
*/
fun collectionSignature(
client: OkHttpClient,
serverUrl: String,
userId: String,
): String? = runCatching {
val base = davAddressBooksBaseUrl(serverUrl, userId)
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:prop><cs:getctag/><d:sync-token/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, base, depth = 1, body)
parseCollectionSignature(xml).ifBlank { null }
}.getOrNull()
/**
* Парсит PROPFIND-ответ в стабильную подпись «href=ctag» (по книгам, отсортировано).
* Regex-парсинг (как ICS в проекте) — чистая функция, тестируется без Android XmlPull.
*/
internal fun parseCollectionSignature(xml: String): String {
val entries = sortedSetOf<String>()
for (m in responseBlockPattern.findAll(xml)) {
val block = m.value
val href = hrefTagPattern.find(block)?.groupValues?.get(1)?.trim().orEmpty()
val tag = (
getctagPattern.find(block)?.groupValues?.get(1)
?: syncTokenPattern.find(block)?.groupValues?.get(1)
)?.trim().orEmpty()
if (href.isNotBlank() && tag.isNotBlank()) {
entries += "$href=$tag"
}
}
return entries.joinToString("\n")
}
private val responseBlockPattern = Regex("(?is)<(?:\\w+:)?response\\b.*?</(?:\\w+:)?response>")
private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)</(?:\\w+:)?href>")
private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)</(?:\\w+:)?getctag>")
private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)</(?:\\w+:)?sync-token>")
fun createContact(
client: OkHttpClient,
serverUrl: String,
@@ -90,7 +166,6 @@ object CardDavClient {
.put(vcard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("Не удалось создать контакт (HTTP ${response.code})")
}
@@ -104,6 +179,58 @@ object CardDavClient {
)
}
/**
* Правка существующего контакта. Берёт СЫРОЙ vCard (round-trip: неизвестные свойства —
* PHOTO, X-*, кастомные — сохраняются), заменяет управляемые поля и делает PUT с If-Match
* по ETag (защита от гонки). Возвращает обновлённый DavContact с новым ETag.
* Сигнатуры list/create не трогаю — это отдельный метод.
*/
fun updateContact(
client: OkHttpClient,
href: String,
etag: String,
rawVCard: String,
fields: VCardFields,
bookName: String = "",
): DavContact {
require(href.isNotBlank()) { "Пустой href контакта — правка невозможна" }
val newVCard = VCardEditor.applyEdits(rawVCard, fields)
val builder = Request.Builder()
.url(href)
.put(newVCard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
if (etag.isNotBlank()) builder.header("If-Match", etag)
val newEtag = client.newCall(builder.build()).execute().use { response ->
if (response.code == 412) {
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
}
if (response.code !in 200..299 && response.code != 204) {
error("Не удалось сохранить контакт (HTTP ${response.code})")
}
response.header("ETag").orEmpty()
}
return davContactFromVCard(newVCard, bookName, href, newEtag.ifBlank { etag })
?: error("Не удалось разобрать сохранённый контакт")
}
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
fun deleteContact(
client: OkHttpClient,
href: String,
etag: String,
) {
require(href.isNotBlank()) { "Пустой href контакта — удаление невозможно" }
val builder = Request.Builder().url(href).delete()
if (etag.isNotBlank()) builder.header("If-Match", etag)
client.newCall(builder.build()).execute().use { response ->
if (response.code == 412) {
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
}
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("Не удалось удалить контакт (HTTP ${response.code})")
}
}
}
private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String {
val fn = displayName.trim()
val parts = fn.split(Regex("\\s+"), limit = 2)
@@ -153,7 +280,7 @@ object CardDavClient {
</d:propfind>
""".trimIndent()
val xml = propfind(client, url, depth = 1, body)
return parseContacts(xml, bookName).take(limit)
return parseContacts(xml, bookName, url).take(limit)
}
private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String {
@@ -163,7 +290,6 @@ object CardDavClient {
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val code = response.code
if (code !in 200..299 && code != 207) {
error("CardDAV error HTTP $code")
@@ -219,47 +345,35 @@ object CardDavClient {
return out
}
private fun parseContacts(xml: String, bookName: String): List<DavContact> {
private fun parseContacts(xml: String, bookName: String, bookUrl: String): List<DavContact> {
val parser = newParser(xml)
val out = mutableListOf<DavContact>()
var inAddressData = false
val data = StringBuilder()
var currentHref = ""
var currentEtag = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> if (parser.localTag() == "address-data") {
inAddressData = true
data.clear()
XmlPullParser.START_TAG -> when (parser.localTag()) {
"response" -> {
currentHref = ""
currentEtag = ""
}
// href до address-data — это путь vCard в текущем <response>
"href" -> if (!inAddressData && currentHref.isBlank()) {
currentHref = parser.readText().trim()
}
"getetag" -> currentEtag = parser.readText().trim()
"address-data" -> {
inAddressData = true
data.clear()
}
}
XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text)
XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) {
val vcard = unfoldVCard(data.toString())
val uid = extract(uidPattern, vcard).ifBlank {
vcard.hashCode().toString()
}
val name = resolveContactName(vcard)
val allEmails = extractAll(emailPattern, vcard)
val allPhones = extractAll(telPattern, vcard)
val email = allEmails.firstOrNull().orEmpty()
val phone = allPhones.firstOrNull().orEmpty()
val photo = parseContactPhoto(vcard)
if (name.isNotBlank() || email.isNotBlank()) {
out += DavContact(
uid = uid,
displayName = name,
email = email,
phone = phone,
bookName = bookName,
photoBase64 = photo?.base64.orEmpty(),
photoMimeType = photo?.mimeType.orEmpty(),
organization = formatOrganization(extract(orgPattern, vcard)),
title = extract(titlePattern, vcard),
address = formatAddress(extract(adrPattern, vcard)),
website = extract(urlPattern, vcard),
birthday = formatBirthday(extract(bdayPattern, vcard)),
emails = allEmails.joinToString("\n"),
phones = allPhones.joinToString("\n"),
)
}
val fullHref = if (currentHref.isBlank()) "" else resolveHref(bookUrl, currentHref)
davContactFromVCard(data.toString(), bookName, fullHref, currentEtag)
?.let { out += it }
inAddressData = false
}
}
@@ -268,6 +382,46 @@ object CardDavClient {
return out
}
/**
* Строит DavContact из сырого vCard. `raw` сохраняем как есть (для round-trip при правке),
* разбор — по развёрнутому (unfold) тексту. Общий для чтения книги и для updateContact.
*/
private fun davContactFromVCard(
rawVCard: String,
bookName: String,
href: String,
etag: String,
): DavContact? {
val vcard = unfoldVCard(rawVCard)
val uid = extract(uidPattern, vcard).ifBlank { vcard.hashCode().toString() }
val name = resolveContactName(vcard)
val allEmails = extractAll(emailPattern, vcard)
val allPhones = extractAll(telPattern, vcard)
val email = allEmails.firstOrNull().orEmpty()
val phone = allPhones.firstOrNull().orEmpty()
val photo = parseContactPhoto(vcard)
if (name.isBlank() && email.isBlank()) return null
return DavContact(
uid = uid,
displayName = name,
email = email,
phone = phone,
bookName = bookName,
photoBase64 = photo?.base64.orEmpty(),
photoMimeType = photo?.mimeType.orEmpty(),
organization = formatOrganization(extract(orgPattern, vcard)),
title = extract(titlePattern, vcard),
address = formatAddress(extract(adrPattern, vcard)),
website = extract(urlPattern, vcard),
birthday = formatBirthday(extract(bdayPattern, vcard)),
emails = allEmails.joinToString("\n"),
phones = allPhones.joinToString("\n"),
href = href,
etag = etag,
raw = rawVCard,
)
}
private fun unfoldVCard(raw: String): String {
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
val lines = normalized.split('\n')
@@ -439,3 +593,119 @@ object CardDavClient {
}
}
}
/**
* Чистый (без сети) редактор vCard 3.0 для round-trip правки. Управляемые свойства
* (FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE) заменяются значениями из [VCardFields];
* все прочие (VERSION, UID, PHOTO, REV, X-* и кастомные) сохраняются из исходного vCard как есть.
* Тестируется юнитами: round-trip неизвестных полей, FN с запятыми, PHOTO base64,
* многострочный (folded) ADR, CRLF-фолдинг.
*/
internal object VCardEditor {
private const val CRLF = "\r\n"
private val MANAGED = setOf(
"FN", "N", "EMAIL", "TEL", "ORG", "TITLE", "ADR", "URL", "BDAY", "NOTE",
)
fun applyEdits(rawVCard: String, fields: VCardFields): String {
val logical = groupLogicalLines(rawVCard)
val preserved = logical.filter { line ->
val p = propertyName(line).uppercase()
p.isNotEmpty() && p != "BEGIN" && p != "END" && p !in MANAGED
}
val hasVersion = preserved.any { propertyName(it).equals("VERSION", ignoreCase = true) }
val sb = StringBuilder()
sb.append("BEGIN:VCARD").append(CRLF)
if (!hasVersion) sb.append("VERSION:3.0").append(CRLF)
preserved.forEach { appendFolded(sb, it) }
emitManaged(sb, fields)
sb.append("END:VCARD").append(CRLF)
return sb.toString()
}
private fun emitManaged(sb: StringBuilder, f: VCardFields) {
val fn = f.displayName.trim()
if (fn.isNotEmpty()) appendFolded(sb, "FN:${escapeText(fn)}")
val last = f.lastName.trim()
val first = f.firstName.trim()
when {
last.isNotEmpty() || first.isNotEmpty() ->
appendFolded(sb, "N:${escapeComponent(last)};${escapeComponent(first)};;;")
fn.isNotEmpty() -> {
// N обязателен в vCard 3.0 — выводим из FN (первое слово — имя, остальное — фамилия)
val parts = fn.split(Regex("\\s+"), limit = 2)
val given = parts.getOrNull(0).orEmpty()
val family = parts.getOrNull(1).orEmpty()
appendFolded(sb, "N:${escapeComponent(family)};${escapeComponent(given)};;;")
}
}
f.emails.filter { it.value.isNotBlank() }.forEach {
appendFolded(sb, "EMAIL;TYPE=${it.type.ifBlank { "INTERNET" }}:${escapeText(it.value.trim())}")
}
f.phones.filter { it.value.isNotBlank() }.forEach {
appendFolded(sb, "TEL;TYPE=${it.type.ifBlank { "CELL" }}:${escapeText(it.value.trim())}")
}
if (f.organization.isNotBlank()) appendFolded(sb, "ORG:${escapeComponent(f.organization.trim())}")
if (f.title.isNotBlank()) appendFolded(sb, "TITLE:${escapeText(f.title.trim())}")
if (f.address.isNotBlank()) {
// плоский адрес → поле street структуры ADR: pobox;ext;street;city;region;zip;country
appendFolded(sb, "ADR;TYPE=HOME:;;${escapeComponent(f.address.trim())};;;;")
}
if (f.website.isNotBlank()) appendFolded(sb, "URL:${escapeText(f.website.trim())}")
if (f.birthday.isNotBlank()) appendFolded(sb, "BDAY:${f.birthday.trim()}")
if (f.note.isNotBlank()) appendFolded(sb, "NOTE:${escapeText(f.note.trim())}")
}
/** Группирует физические строки в логические: продолжение начинается с ' ' или '\t'. */
private fun groupLogicalLines(raw: String): List<String> {
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
val out = mutableListOf<String>()
val cur = StringBuilder()
for (line in normalized.split('\n')) {
if (line.isEmpty()) continue
if ((line[0] == ' ' || line[0] == '\t') && cur.isNotEmpty()) {
cur.append(line.substring(1))
} else {
if (cur.isNotEmpty()) out += cur.toString()
cur.setLength(0)
cur.append(line)
}
}
if (cur.isNotEmpty()) out += cur.toString()
return out
}
private fun propertyName(logicalLine: String): String {
val end = logicalLine.indexOfFirst { it == ':' || it == ';' }
val head = if (end < 0) logicalLine else logicalLine.substring(0, end)
// свойство может иметь group-префикс "item1.EMAIL" — берём часть после точки
return head.substringAfterLast('.').trim()
}
/** Фолдинг длинных строк по 75 символов с CRLF+пробел (RFC 2426 §2.6). */
private fun appendFolded(sb: StringBuilder, line: String) {
if (line.length <= 75) {
sb.append(line).append(CRLF)
return
}
var i = 0
var first = true
while (i < line.length) {
val take = if (first) 75 else 74
val end = minOf(i + take, line.length)
if (!first) sb.append(' ')
sb.append(line, i, end).append(CRLF)
i = end
first = false
}
}
// TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
private fun escapeText(v: String): String =
v.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "")
// компонент структурного значения (N/ADR/ORG): дополнительно экранируем разделители ; и ,
private fun escapeComponent(v: String): String =
escapeText(v).replace(";", "\\;").replace(",", "\\,")
}
@@ -27,9 +27,6 @@ object DavClient {
.method("MKCOL", null)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("DAV MKCOL HTTP ${response.code}")
}
@@ -46,9 +43,6 @@ object DavClient {
.put(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("DAV upload HTTP ${response.code}")
}
@@ -76,9 +70,6 @@ object DavClient {
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
val code = response.code
if (code !in 200..299 && code != 207) {
error("DAV error HTTP $code")
@@ -182,7 +173,6 @@ object DavClient {
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204) {
error("DAV DELETE HTTP ${response.code}")
}
@@ -197,7 +187,6 @@ object DavClient {
.header("Overwrite", "T")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("DAV MOVE HTTP ${response.code}")
}
@@ -221,7 +210,6 @@ object DavClient {
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 207) {
error("DAV PROPPATCH HTTP ${response.code}")
}
@@ -187,25 +187,40 @@ object LoginFlowClient {
}
}
private data class CredentialParams(
internal data class CredentialParams(
val server: String,
val user: String,
val password: String,
)
private fun parseCredentialParams(params: String): CredentialParams? {
val values = params.split('&')
if (values.isEmpty() || values.size > 3) return null
var server = ""
var user = ""
var password = ""
values.forEach { value ->
when {
value.startsWith("user:") -> user = decode(value.removePrefix("user:"))
value.startsWith("server:") -> server = decode(value.removePrefix("server:"))
value.startsWith("password:") -> password = decode(value.removePrefix("password:"))
/**
* Разбор `server:...&user:...&password:...` (формат nc-login). Значения НЕ split('&'):
* пароль может содержать `&` и `:`, а раньше `split('&')`+`size>3` его резал/ронял вход.
* Ищем маркеры `key:` (в начале или после `&`) и берём значение до следующего маркера.
*/
internal fun parseCredentialParams(params: String): CredentialParams? {
val keys = listOf("server", "user", "password")
data class Marker(val key: String, val at: Int, val valueAt: Int)
val markers = mutableListOf<Marker>()
for (key in keys) {
if (params.startsWith("$key:")) markers += Marker(key, 0, key.length + 1)
var idx = params.indexOf("&$key:")
while (idx >= 0) {
markers += Marker(key, idx, idx + 1 + key.length + 1)
idx = params.indexOf("&$key:", idx + 1)
}
}
if (markers.isEmpty()) return null
markers.sortBy { it.at }
val map = mutableMapOf<String, String>()
markers.forEachIndexed { i, m ->
val end = if (i + 1 < markers.size) markers[i + 1].at else params.length
// первое вхождение ключа выигрывает (не перезатираем более поздним мусором)
map.putIfAbsent(m.key, decode(params.substring(m.valueAt, end)))
}
val server = map["server"].orEmpty()
val user = map["user"].orEmpty()
val password = map["password"].orEmpty()
if (server.isBlank() || user.isBlank() || password.isBlank()) return null
return CredentialParams(server, user, password)
}
@@ -1,23 +1,65 @@
package ru.forbion.f7cloud.core.network
import android.content.Context
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* Фабрика HTTP-клиентов. Раньше создавала НОВЫЙ OkHttpClient на КАЖДЫЙ запрос (~32 места)
* TLS-handshake и новый пул/диспатчер на запрос, утечка ExecutorService. Теперь:
* - один базовый клиент (общий ConnectionPool + Dispatcher + диск-кэш);
* - авторизованные варианты через `base.newBuilder()` (шарят пул/диспатчер/кэш);
* - экземпляры кэшируются по кредам+таймаутам переиспользуются, соединения живут.
*
* Публичный API совместим вызывающие (`newAuthedClient*`) работают как прежде.
* 401UnauthorizedException централизован через interceptor (`throwOnUnauthorized`, по умолч.
* true) ручные проверки в репозиториях убраны. Login-пути (AuthVerifier), где 401 = «неверный
* пароль» со своим сообщением, передают `throwOnUnauthorized = false`.
*/
object NetworkFactory {
@Volatile
private var base: OkHttpClient = OkHttpClient()
private val clients = ConcurrentHashMap<String, OkHttpClient>()
/** Вызывать один раз из Application.onCreate — добавляет диск-кэш (нужен cacheDir). */
fun init(context: Context) {
val cacheDir = File(context.applicationContext.cacheDir, "http-cache")
base = OkHttpClient.Builder()
.cache(Cache(cacheDir, 20L * 1024 * 1024)) // 20 МБ
.build()
clients.clear() // пересобрать производные клиенты уже с кэшем
}
/** Билдер на основе базового клиента (пул/кэш) c опциональным trust-all TLS, без auth. */
fun newBaseBuilder(trustAllCerts: Boolean = false): OkHttpClient.Builder =
base.newBuilder()
.callTimeout(30, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.applyUnsafeSslIfNeeded(trustAllCerts)
fun newAuthedClient(
username: String,
appPassword: String,
trustAllCerts: Boolean = false,
callTimeoutSeconds: Long = 30,
readTimeoutSeconds: Long = 30,
throwOnUnauthorized: Boolean = true,
): OkHttpClient {
return OkHttpClient.Builder()
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.applyUnsafeSslIfNeeded(trustAllCerts)
.addInterceptor(BasicAuthInterceptor(username, appPassword))
.build()
val key = "$username|$appPassword|$trustAllCerts|$callTimeoutSeconds|$readTimeoutSeconds|$throwOnUnauthorized"
return clients.getOrPut(key) {
base.newBuilder() // общий пул/диспатчер/кэш базового клиента
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.applyUnsafeSslIfNeeded(trustAllCerts)
.addInterceptor(BasicAuthInterceptor(username, appPassword))
.apply { if (throwOnUnauthorized) addInterceptor(UnauthorizedInterceptor) }
.build()
}
}
/** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */
@@ -0,0 +1,82 @@
package ru.forbion.f7cloud.core.network
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
/**
* Устанавливает НАСТОЯЩУЮ веб-сессию Nextcloud (cookie + CSRF) по форме `/login`, используя
* app-пароль как пароль (он валиден для входа и обходит 2FA). Нужно для встраивания веб-viewer'ов
* (доска Whiteboard и т.п.), которым мало Basic-auth: их Vue-XHR ходят по сессии.
*/
object NextcloudWebSession {
private val requestTokenRegex = Regex(
"""(?:name=["']requesttoken["']\s+value|<meta\s+name=["']requesttoken["']\s+content)=["']([^"']+)["']""",
RegexOption.IGNORE_CASE,
)
/** Cookie, собранные при логине (для переноса в WebView CookieManager). */
fun login(
serverUrl: String,
username: String,
appPassword: String,
trustAllCerts: Boolean = false,
): List<Cookie> {
val base = serverUrl.trimEnd('/')
val host = base.toHttpUrl().host
val jar = MemoryCookieJar()
val client = NetworkFactory.newBaseBuilder(trustAllCerts)
.cookieJar(jar)
.followRedirects(true)
.followSslRedirects(true)
.build()
// 1. GET страницы логина — начальные cookie + requesttoken.
val loginHtml = client.newCall(
Request.Builder().url("$base/login").header("User-Agent", USER_AGENT).get().build(),
).execute().use { it.body?.string().orEmpty() }
val token = requestTokenRegex.find(loginHtml)?.groupValues?.getOrNull(1).orEmpty()
// 2. POST формы логина app-паролем — сервер ставит session-cookie.
val form = FormBody.Builder()
.add("user", username)
.add("password", appPassword)
.apply { if (token.isNotBlank()) add("requesttoken", token) }
.add("remember_login", "1")
.add("timezone", "UTC")
.add("timezone_offset", "0")
.build()
client.newCall(
Request.Builder()
.url("$base/login")
.header("User-Agent", USER_AGENT)
.apply { if (token.isNotBlank()) header("requesttoken", token) }
.header("OCS-APIRequest", "true")
.post(form)
.build(),
).execute().use { /* cookie осели в jar */ }
return jar.cookiesForHost(host)
}
private const val USER_AGENT = "Mozilla/5.0 (Android) F7cloud-Mobile"
private class MemoryCookieJar : CookieJar {
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { c ->
list.removeAll { it.name == c.name }
list += c
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
store[url.host].orEmpty().filter { it.matches(url) }
fun cookiesForHost(host: String): List<Cookie> = store[host].orEmpty().toList()
}
}
@@ -30,7 +30,6 @@ class NotificationsRepository {
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Уведомления HTTP ${response.code}")
}
@@ -86,7 +85,6 @@ class NotificationsRepository {
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}")
}
@@ -0,0 +1,22 @@
package ru.forbion.f7cloud.core.network
import okhttp3.Interceptor
import okhttp3.Response
/**
* Единая точка обработки 401 для авторизованных запросов: бросает [UnauthorizedException]
* (session expired logout). Заменяет ~70 ручных `if (code == 401) throw ...` по репозиториям.
*
* Подключается в [NetworkFactory] при `throwOnUnauthorized = true` (по умолчанию). Login-пути
* (проверка пароля при входе) НЕ используют этот interceptor там 401 = «неверный пароль».
*/
object UnauthorizedInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == 401) {
response.close() // не течём телом — дальше по цепочке оно не читается
throw UnauthorizedException()
}
return response
}
}
@@ -0,0 +1,104 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import org.junit.Test
/**
* Тесты парсера ICS. Фиксируют баги из аудита (2026-07-07): зоны/TZID, ATTENDEE, unescape.
* Даты с суффиксом Z дают абсолютный Instant не зависят от таймзоны машины.
*/
class CalendarIcsTest {
// --- parseIcsInstant: зоны ---
@Test fun `Z-суффикс парсится как UTC`() {
// Баг: раньше Z срезался и время трактовалось как локальное.
val expected = Instant.parse("2026-07-07T10:00:00Z")
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000Z"))
}
@Test fun `floating без Z — локальная зона`() {
val expected = LocalDateTime.of(2026, 7, 7, 10, 0, 0)
.atZone(ZoneId.systemDefault()).toInstant()
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000"))
}
@Test fun `TZID учитывается`() {
// 12:00 в Москве (UTC+3) == 09:00 UTC
val expected = Instant.parse("2026-07-07T09:00:00Z")
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T120000", "Europe/Moscow"))
}
@Test fun `VALUE=DATE — начало дня`() {
val expected = java.time.LocalDate.of(2026, 7, 7)
.atStartOfDay(ZoneId.systemDefault()).toInstant()
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707"))
}
// --- ATTENDEE: несколько участников с параметрами ---
@Test fun `несколько ATTENDEE не схлопываются и хранят CN-PARTSTAT`() {
val ics = """
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:test-1
DTSTART:20260707T100000Z
SUMMARY:Встреча
ATTENDEE;CN=Иван Петров;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:ivan@f7.ru
ATTENDEE;CN=Мария Сидорова;PARTSTAT=DECLINED;ROLE=OPT-PARTICIPANT:mailto:maria@f7.ru
END:VEVENT
END:VCALENDAR
""".trimIndent()
val event = CalendarIcs.parseSingle(ics)!!
assertEquals(2, event.attendees.size)
val ivan = event.attendees.first { it.email == "ivan@f7.ru" }
assertEquals("Иван Петров", ivan.displayName)
assertEquals("ACCEPTED", ivan.partStat)
val maria = event.attendees.first { it.email == "maria@f7.ru" }
assertEquals("Мария Сидорова", maria.displayName)
assertEquals("DECLINED", maria.partStat)
}
// --- escape/unescape: круговой round-trip со спецсимволами ---
@Test fun `summary со спецсимволами переживает build и parse`() {
val summary = """Смета: 50%, скидка; путь C:\tmp\файл и
перенос""".trimIndent()
val data = CalendarEventData(
uid = "rt-1",
summary = summary,
startEpochMilli = Instant.parse("2026-07-07T10:00:00Z").toEpochMilli(),
endEpochMilli = Instant.parse("2026-07-07T11:00:00Z").toEpochMilli(),
)
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
assertEquals(summary, parsed.summary)
}
@Test fun `unescape не превращает экранированный бэкслеш-n в перенос строки`() {
// literal "\n" (бэкслеш+n) в тексте: escape → "\\n", unescape должен вернуть "\n" (2 символа),
// а не перевод строки. Проверяем через round-trip.
val summary = """путь\name"""
val data = CalendarEventData(
uid = "rt-2",
summary = summary,
startEpochMilli = 0L,
endEpochMilli = 3600_000L,
)
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
assertEquals(summary, parsed.summary)
assertTrue("не должно быть переноса строки", !parsed.summary.contains('\n'))
}
// --- согласованность двух парсеров ---
@Test fun `CalDavClient и CalendarIcs парсят одинаково`() {
val values = listOf("20260707T100000Z", "20260707", "20251231T235900Z")
for (v in values) {
assertEquals("рассинхрон на $v", CalendarIcs.parseIcsInstant(v), CalDavClient.parseIcsInstant(v))
}
}
}
@@ -0,0 +1,56 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Тесты подписи коллекций CardDAV (CTag/sync-token) основа инкрементальной
* синхронизации: если подпись не изменилась, контакты не перекачиваются.
*/
class CardDavSignatureTest {
private val ns = """xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/""""
private fun body(vararg responses: String) =
"""<?xml version="1.0"?><d:multistatus $ns>${responses.joinToString("")}</d:multistatus>"""
private fun resp(href: String, ctag: String? = null, token: String? = null) = """
<d:response>
<d:href>$href</d:href>
<d:propstat><d:prop>
${ctag?.let { "<cs:getctag>$it</cs:getctag>" } ?: ""}
${token?.let { "<d:sync-token>$it</d:sync-token>" } ?: ""}
</d:prop></d:propstat>
</d:response>
""".trimIndent()
@Test fun ctag_signature_is_stable_and_sorted() {
val a = CardDavClient.parseCollectionSignature(
body(resp("/dav/addressbooks/u/b1/", ctag = "111"), resp("/dav/addressbooks/u/b2/", ctag = "222")),
)
// порядок ответов не влияет на подпись
val b = CardDavClient.parseCollectionSignature(
body(resp("/dav/addressbooks/u/b2/", ctag = "222"), resp("/dav/addressbooks/u/b1/", ctag = "111")),
)
assertEquals(a, b)
assertTrue(a.contains("/dav/addressbooks/u/b1/=111"))
assertTrue(a.contains("/dav/addressbooks/u/b2/=222"))
}
@Test fun ctag_change_changes_signature() {
val before = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "111")))
val after = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "999")))
assertNotEquals(before, after)
}
@Test fun sync_token_fallback_when_no_ctag() {
val sig = CardDavClient.parseCollectionSignature(body(resp("/b1/", token = "http://sabre/sync/42")))
assertEquals("/b1/=http://sabre/sync/42", sig)
}
@Test fun empty_when_no_tags() {
assertEquals("", CardDavClient.parseCollectionSignature(body(resp("/b1/"))))
}
}
@@ -0,0 +1,38 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Разбор параметров nc-login QR. Фиксирует баг: пароль с `&` терялся (split('&')+size>3). */
class LoginFlowClientTest {
@Test fun `пароль с амперсандом и двоеточием не теряется`() {
val r = LoginFlowClient.parseCredentialParams(
"server:https://cloud.f7.ru&user:ivan&password:a&b:c&d",
)!!
assertEquals("https://cloud.f7.ru", r.server)
assertEquals("ivan", r.user)
assertEquals("a&b:c&d", r.password)
}
@Test fun `порядок полей произвольный`() {
val r = LoginFlowClient.parseCredentialParams(
"user:masha&password:p@ss&server:https://x.ru",
)!!
assertEquals("https://x.ru", r.server)
assertEquals("masha", r.user)
assertEquals("p@ss", r.password)
}
@Test fun `простой пароль без спецсимволов`() {
val r = LoginFlowClient.parseCredentialParams(
"server:https://x.ru&user:u&password:simple",
)!!
assertEquals("simple", r.password)
}
@Test fun `нет обязательного поля — null`() {
assertNull(LoginFlowClient.parseCredentialParams("server:https://x.ru&user:u"))
}
}
@@ -0,0 +1,29 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Test
/** Фиксирует переиспользование клиента и общий пул соединений (было: новый клиент на запрос). */
class NetworkFactoryTest {
@Test fun `одинаковые креды дают ТОТ ЖЕ экземпляр клиента`() {
val a = NetworkFactory.newAuthedClient("user", "pass", false)
val b = NetworkFactory.newAuthedClient("user", "pass", false)
assertSame("клиент должен переиспользоваться, а не создаваться заново", a, b)
}
@Test fun `клиенты шарят общий ConnectionPool и Dispatcher`() {
val a = NetworkFactory.newAuthedClient("u1", "p1", false)
val b = NetworkFactory.newAuthedClient("u2", "p2", false) // другие креды
assertSame(a.connectionPool, b.connectionPool)
assertSame(a.dispatcher, b.dispatcher)
}
@Test fun `разные таймауты — разные клиенты, но общий пул`() {
val normal = NetworkFactory.newAuthedClient("u", "p", false)
val office = NetworkFactory.newAuthedClientForOffice("u", "p", false)
assertNotSame(normal, office)
assertSame(normal.connectionPool, office.connectionPool)
}
}
@@ -0,0 +1,40 @@
package ru.forbion.f7cloud.core.network
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
/** Централизованная обработка 401: interceptor бросает UnauthorizedException вместо ручных проверок. */
class UnauthorizedInterceptorTest {
private lateinit var server: MockWebServer
@Before fun setUp() { server = MockWebServer().apply { start() } }
@After fun tearDown() { server.shutdown() }
private fun call(client: OkHttpClient) =
client.newCall(Request.Builder().url(server.url("/x")).build()).execute()
@Test(expected = UnauthorizedException::class)
fun `401 бросает UnauthorizedException`() {
server.enqueue(MockResponse().setResponseCode(401))
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
call(client)
}
@Test fun `не-401 проходит как обычно`() {
server.enqueue(MockResponse().setResponseCode(200).setBody("ok"))
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
call(client).use { assertEquals(200, it.code) }
}
@Test fun `403 не трактуется как Unauthorized`() {
server.enqueue(MockResponse().setResponseCode(403))
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
call(client).use { assertEquals(403, it.code) }
}
}
@@ -0,0 +1,138 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Тесты round-trip редактора vCard (P1 правка контакта, mail/072).
* Ключевое: неизвестные свойства (PHOTO, X-*, UID) сохраняются, управляемые заменяются.
* Краевые из письма лида: FN с запятыми, PHOTO base64, многострочный ADR, CRLF-фолдинг.
*/
class VCardEditorTest {
private fun edit(raw: String, fields: VCardFields) = VCardEditor.applyEdits(raw, fields)
private val base = VCardFields(displayName = "Иван Петров")
@Test fun preserves_unknown_properties_and_uid_and_photo() {
val raw = buildString {
append("BEGIN:VCARD\r\n")
append("VERSION:3.0\r\n")
append("UID:abc-123\r\n")
append("FN:Старое Имя\r\n")
append("N:Имя;Старое;;;\r\n")
append("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD\r\n")
append("X-CUSTOM-FIELD:держим-как-есть\r\n")
append("TEL;TYPE=CELL:+70000000000\r\n")
append("END:VCARD\r\n")
}
val out = edit(
raw,
VCardFields(
displayName = "Иван Петров",
phones = listOf(VCardTypedValue("+79990001122", "WORK")),
),
)
// UID и неизвестные свойства сохранены
assertTrue("UID сохранён", out.contains("UID:abc-123"))
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
assertTrue("X-CUSTOM сохранён", out.contains("X-CUSTOM-FIELD:держим-как-есть"))
// управляемые поля заменены на новые
assertTrue("новый FN", out.contains("FN:Иван Петров"))
assertFalse("старый FN убран", out.contains("Старое Имя"))
assertTrue("новый телефон", out.contains("TEL;TYPE=WORK:+79990001122"))
assertFalse("старый телефон убран", out.contains("+70000000000"))
// валидный каркас
assertTrue(out.startsWith("BEGIN:VCARD\r\n"))
assertTrue(out.trimEnd().endsWith("END:VCARD"))
assertEquals("одна VERSION", 1, Regex("(?m)^VERSION:").findAll(out).count())
}
@Test fun fn_with_commas_is_kept_literally() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:X\r\nEND:VCARD\r\n"
val out = edit(raw, VCardFields(displayName = "Петров, Иван, Иванович"))
assertTrue("запятые в FN сохранены литералом", out.contains("FN:Петров, Иван, Иванович"))
}
@Test fun folded_multiline_adr_is_regrouped_and_replaced() {
// ADR разбит фолдингом на 2 физические строки (продолжение с пробела)
val raw = buildString {
append("BEGIN:VCARD\r\n")
append("VERSION:3.0\r\n")
append("UID:y\r\n")
append("FN:Кто-то\r\n")
append("ADR;TYPE=HOME:;;ул. Очень Длинная Дом 1 Корпус 2 Строение 3\r\n")
append(" Квартира 456 Подъезд 7;Город;;;\r\n")
append("END:VCARD\r\n")
}
val out = edit(raw, VCardFields(displayName = "Кто-то", address = "Новый Город, ул. Новая 5"))
assertTrue("новый адрес в ADR", out.contains("Новый Город"))
assertFalse("старый адрес убран", out.contains("Очень Длинная"))
// не осталось «висящей» строки-продолжения старого ADR
assertFalse(out.contains("Квартира 456"))
}
@Test fun long_line_is_folded_at_75_with_crlf_space() {
val longNote = "Z".repeat(200)
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:N\r\nEND:VCARD\r\n"
val out = edit(raw, base.copy(note = longNote))
// каждая физическая строка ≤ 76 символов (75 + возможный ведущий пробел)
val physical = out.split("\r\n").filter { it.isNotEmpty() }
assertTrue("все строки свёрнуты ≤76", physical.all { it.length <= 76 })
// продолжения начинаются с пробела
assertTrue("есть свёрнутое продолжение", physical.any { it.startsWith(" Z") })
// при развороте NOTE восстанавливается целиком
val unfolded = out.replace("\r\n ", "")
assertTrue(unfolded.contains("NOTE:$longNote"))
}
@Test fun crlf_and_lf_inputs_both_parse() {
val lf = "BEGIN:VCARD\nVERSION:3.0\nUID:m\nFN:Старое\nX-KEEP:1\nEND:VCARD\n"
val out = edit(lf, base)
assertTrue(out.contains("X-KEEP:1"))
assertTrue(out.contains("FN:Иван Петров"))
// выход всегда CRLF
assertTrue(out.contains("\r\n"))
assertFalse("нет одиночных LF", out.replace("\r\n", "").contains("\n"))
}
@Test fun n_derived_from_fn_when_name_parts_absent() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nEND:VCARD\r\n"
val out = edit(raw, VCardFields(displayName = "Иван Петров"))
// N: фамилия;имя;;; — из FN: given=Иван, family=Петров
assertTrue(out.contains("N:Петров;Иван;;;"))
}
@Test fun explicit_name_parts_win_over_fn() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:e\r\nEND:VCARD\r\n"
val out = edit(raw, VCardFields(displayName = "Ivan P", firstName = "Ivan", lastName = "Petrov"))
assertTrue(out.contains("N:Petrov;Ivan;;;"))
}
@Test fun multiple_emails_and_types_emitted() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:me\r\nFN:X\r\nEMAIL:old@x.ru\r\nEND:VCARD\r\n"
val out = edit(
raw,
base.copy(
emails = listOf(
VCardTypedValue("home@x.ru", "HOME"),
VCardTypedValue("work@x.ru", "WORK"),
),
),
)
assertFalse(out.contains("old@x.ru"))
assertTrue(out.contains("EMAIL;TYPE=HOME:home@x.ru"))
assertTrue(out.contains("EMAIL;TYPE=WORK:work@x.ru"))
}
@Test fun blank_managed_fields_are_omitted() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:b\r\nFN:X\r\nEND:VCARD\r\n"
val out = edit(raw, VCardFields(displayName = "Только Имя"))
assertFalse("нет пустого EMAIL", out.contains("EMAIL"))
assertFalse("нет пустого TEL", out.contains("TEL"))
assertFalse("нет пустого ADR", out.contains("ADR"))
assertFalse("нет пустого ORG", out.contains("ORG"))
}
}
+5 -4
View File
@@ -21,8 +21,9 @@ android {
dependencies {
implementation project(':core:auth')
implementation 'com.google.firebase:firebase-messaging:24.0.1'
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.core:core:1.15.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation platform(libs.firebase.bom)
implementation libs.firebase.messaging
implementation libs.core.ktx
implementation libs.core
implementation libs.coroutines.android
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:F7IncomingCallQueue.kt$F7IncomingCallQueue$(R.string.incoming_call_subtitle)</ID>
<ID>ArgumentListWrapping:F7IncomingCallQueue.kt$F7IncomingCallQueue$(call.displayName.ifBlank { call.title.ifBlank { context.getString(R.string.incoming_call_subtitle) } })</ID>
<ID>ArgumentListWrapping:F7NotificationChannels.kt$F7NotificationChannels$(SERVER_MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)</ID>
<ID>ComplexCondition:F7PushEventParser.kt$F7PushEventParser$type == "mail" || source == "mail" || lowerUrl.contains("/apps/f7mail") || lowerUrl.contains("/apps/mail")</ID>
<ID>MaximumLineLength:F7IncomingCallQueue.kt$F7IncomingCallQueue$ </ID>
<ID>MaximumLineLength:F7NotificationChannels.kt$F7NotificationChannels$ </ID>
<ID>NestedBlockDepth:F7FirebaseMessagingService.kt$F7FirebaseMessagingService$override fun onMessageReceived(message: RemoteMessage)</ID>
<ID>SpacingBetweenDeclarationsWithComments:F7NotificationChannels.kt$F7NotificationChannels$/** Silent channel: ringtone is played only by [F7IncomingCallRinger]. */</ID>
<ID>UnusedParameter:F7IncomingCallQueue.kt$F7IncomingCallQueue$alert: Boolean</ID>
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val callVibration = longArrayOf(0, 500, 200, 500)</ID>
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val ringtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)</ID>
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val ringtoneAudio = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build()</ID>
<ID>UseCheckOrError:F7IncomingCallQueue.kt$F7IncomingCallQueue$throw IllegalStateException("NotificationManager unavailable")</ID>
</CurrentIssues>
</SmellBaseline>
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Handles Decline on incoming Talk call notifications. */
/** Handles Decline and ring-timeout on incoming Talk call notifications. */
class F7CallActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
when (intent.action) {
ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
}
}
companion object {
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
const val ACTION_CALL_TIMEOUT = "ru.forbion.f7cloud.action.CALL_TIMEOUT"
const val EXTRA_NOTIFICATION_ID = "notificationId"
const val EXTRA_ROOM_TOKEN = "roomToken"
}
@@ -1,10 +1,13 @@
package ru.forbion.f7cloud.core.push
import android.app.AlarmManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
private const val KEY_QUEUE = "queue"
private const val KEY_ACTIVE_TOKEN = "active_token"
private const val KEY_ACTIVE_AT = "active_at"
private const val KEY_ACTIVE_CALL = "active_call"
const val ACTIVE_NOTIFICATION_ID = 5000
private const val MISSED_NOTIFICATION_BASE = 5100
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
/** Входящий звонит 30 с; дальше — авто-сброс и уведомление «Пропущенный звонок». */
private const val RING_TIMEOUT_MS = 30_000L
/** Запас страховочного alarm поверх основного in-process таймера. */
private const val TIMEOUT_ALARM_SLACK_MS = 5_000L
private val lock = Any()
private val handler = Handler(Looper.getMainLooper())
private var timeoutRunnable: Runnable? = null
private val callEndedListeners = java.util.concurrent.CopyOnWriteArraySet<(String) -> Unit>()
/** Уведомляет UI (полноэкранный входящий), что активный звонок снят: принят/отклонён/протух. */
fun addCallEndedListener(listener: (String) -> Unit) {
callEndedListeners.add(listener)
}
fun removeCallEndedListener(listener: (String) -> Unit) {
callEndedListeners.remove(listener)
}
private fun notifyCallEnded(token: String) {
callEndedListeners.forEach { listener ->
runCatching { listener(token) }
}
}
fun enqueue(
context: Context,
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
}
if (active.isEmpty()) {
setActive(prefs, token)
val shown = showNotification(context, call, waiting = 0)
if (!shown) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
if (shown) {
setActive(prefs, call)
scheduleTimeout(context, token)
}
return shown
}
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
}
fun dismissAndShowNext(context: Context, roomToken: String?) {
var ended: String? = null
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
@@ -88,36 +118,74 @@ object F7IncomingCallQueue {
}
}
cancelTimeout(context)
cancelNotification(context)
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
if (queue.length() == 0) {
prefs.edit().remove(KEY_QUEUE).apply()
return
}
runCatching {
val next = PendingCall.fromJson(queue.getJSONObject(0))
val rest = JSONArray()
for (i in 1 until queue.length()) {
rest.put(queue.get(i))
}
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
if (showNotification(context, next, rest.length())) {
setActive(prefs, next.roomToken)
}
}.onFailure {
Log.w(TAG, "Failed to parse queued call", it)
prefs.edit().remove(KEY_QUEUE).apply()
}
clearActive(prefs)
ended = active.takeIf { it.isNotEmpty() }
advanceQueueLocked(context, prefs, queue)
}
ended?.let { notifyCallEnded(it) }
}
/**
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
* Принятые/отклонённые звонки сюда не попадают их снимает [dismissAndShowNext].
*/
fun timeoutActive(context: Context, roomToken: String?) {
var ended: String? = null
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
if (active.isEmpty()) return
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
val call = readActiveCall(prefs)
cancelTimeout(context)
cancelNotification(context)
clearActive(prefs)
ended = active
Log.i(TAG, "Call timed out (unanswered): $active")
if (call != null) {
showMissedNotification(context, call)
}
advanceQueueLocked(context, prefs, readQueue(prefs))
}
ended?.let { notifyCallEnded(it) }
}
fun clearAll(context: Context) {
synchronized(lock) {
val prefs = prefs(context)
cancelTimeout(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
}
}
private fun advanceQueueLocked(
context: Context,
prefs: android.content.SharedPreferences,
queue: JSONArray,
) {
if (queue.length() == 0) {
prefs.edit().remove(KEY_QUEUE).apply()
return
}
runCatching {
val next = PendingCall.fromJson(queue.getJSONObject(0))
val rest = JSONArray()
for (i in 1 until queue.length()) {
rest.put(queue.get(i))
}
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
if (showNotification(context, next, rest.length())) {
setActive(prefs, next)
scheduleTimeout(context, next.roomToken)
}
}.onFailure {
Log.w(TAG, "Failed to parse queued call", it)
prefs.edit().remove(KEY_QUEUE).apply()
}
}
@@ -241,7 +309,8 @@ object F7IncomingCallQueue {
.setStyle(callStyle)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(genericCallPublic(context, intents.preview))
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
@@ -282,7 +351,8 @@ object F7IncomingCallQueue {
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(genericCallPublic(context, intents.preview))
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
@@ -304,6 +374,21 @@ object F7IncomingCallQueue {
?: throw IllegalStateException("NotificationManager unavailable")
}
/**
* Обезличенная (public) версия уведомления звонка для локскрина: показывает лишь «Входящий
* звонок» без имени звонящего/комнаты (минимум информации, решение владельца). Полное имя
* в полноэкранном UI звонка/после разблокировки. Full-screen intent работает независимо от visibility.
*/
private fun genericCallPublic(context: Context, preview: PendingIntent): android.app.Notification =
NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
.setContentTitle("F7cloud")
.setContentText("Входящий звонок")
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentIntent(preview)
.build()
private fun resolveJoinUrl(context: Context, call: PendingCall): String? {
val raw = call.acceptUrl?.takeIf { it.isNotBlank() }
?: AuthStore(context).load()?.let { session ->
@@ -326,13 +411,106 @@ object F7IncomingCallQueue {
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
private fun setActive(prefs: android.content.SharedPreferences, call: PendingCall) {
prefs.edit()
.putString(KEY_ACTIVE_TOKEN, token)
.putString(KEY_ACTIVE_TOKEN, call.roomToken)
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
// показать «Пропущенный» с названием комнаты.
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
.apply()
}
private fun clearActive(prefs: android.content.SharedPreferences) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
}
private fun readActiveCall(prefs: android.content.SharedPreferences): PendingCall? {
val raw = prefs.getString(KEY_ACTIVE_CALL, null) ?: return null
return runCatching { PendingCall.fromJson(JSONObject(raw)) }.getOrNull()
}
private fun scheduleTimeout(context: Context, token: String) {
val app = context.applicationContext
timeoutRunnable?.let(handler::removeCallbacks)
val runnable = Runnable { timeoutActive(app, token) }
timeoutRunnable = runnable
handler.postDelayed(runnable, RING_TIMEOUT_MS)
// Страховка: если процесс умрёт до срабатывания таймера, alarm поднимет его и
// заменит зависшее уведомление звонка на «Пропущенный». timeoutActive идемпотентен.
runCatching {
app.getSystemService(AlarmManager::class.java)?.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + RING_TIMEOUT_MS + TIMEOUT_ALARM_SLACK_MS,
timeoutAlarmIntent(app, token),
)
}.onFailure { Log.w(TAG, "Timeout alarm schedule failed", it) }
}
private fun cancelTimeout(context: Context) {
timeoutRunnable?.let(handler::removeCallbacks)
timeoutRunnable = null
runCatching {
context.applicationContext.getSystemService(AlarmManager::class.java)
?.cancel(timeoutAlarmIntent(context.applicationContext, null))
}
}
private fun timeoutAlarmIntent(context: Context, token: String?): PendingIntent {
val intent = Intent(context, F7CallActionReceiver::class.java).apply {
action = F7CallActionReceiver.ACTION_CALL_TIMEOUT
if (token != null) {
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, token)
}
}
return PendingIntent.getBroadcast(
context,
ACTIVE_NOTIFICATION_ID + 2,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun showMissedNotification(context: Context, call: PendingCall) {
F7NotificationChannels.ensureAll(context)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val roomName = call.displayName.ifBlank { call.title }
val notificationId = MISSED_NOTIFICATION_BASE + kotlin.math.abs(call.roomToken.hashCode() % 1000)
val pending = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
PendingIntent.getActivity(
context,
notificationId,
Intent(launch).putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.sym_call_missed
// Локскрин — обезличенно (минимум информации, решение владельца); название комнаты
// видно после разблокировки.
val publicVersion = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
.setSmallIcon(iconRes)
.setContentTitle("F7cloud")
.setContentText(context.getString(R.string.call_missed_public))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.apply { if (pending != null) setContentIntent(pending) }
.build()
val notification = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
.setSmallIcon(iconRes)
.setContentTitle(context.getString(R.string.call_missed_title))
.setContentText(roomName)
.setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.apply { if (pending != null) setContentIntent(pending) }
.build()
runCatching { manager.notify(notificationId, notification) }
.onFailure { Log.w(TAG, "Missed-call notification failed", it) }
}
private fun touchActive(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
}
@@ -343,7 +521,7 @@ object F7IncomingCallQueue {
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
Log.w(TAG, "Clearing stale active call: $active")
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
clearActive(prefs)
}
}
@@ -48,7 +48,10 @@ object F7NotificationChannels {
setSound(sound, soundAttrs)
}
setShowBadge(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
// Приватно на локскрине: система покажет public-версию (generic), а не полный
// контент (минимум информации, решение владельца). Public-версия задаётся
// per-notification в F7PushNotificationHelper / F7IncomingCallQueue.
lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE
},
)
}
@@ -49,6 +49,21 @@ object F7PushNotificationHelper {
}
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.stat_notify_chat
// На локскрине — ОБЕЗЛИЧЕННАЯ версия (минимум информации, решение владельца): отправитель
// и текст не утекают на заблокированном экране. Полный контент — после разблокировки.
val genericText = when (type) {
"chat", "room", "message" -> "Новое сообщение"
"call" -> "Входящий звонок"
else -> "Новое уведомление"
}
val publicVersion = NotificationCompat.Builder(context, channel)
.setSmallIcon(iconRes)
.setContentTitle("F7cloud")
.setContentText(genericText)
.setAutoCancel(true)
.setPriority(priority)
.setContentIntent(pending)
.build()
val notification = NotificationCompat.Builder(context, channel)
.setSmallIcon(iconRes)
.setContentTitle(title)
@@ -56,6 +71,8 @@ object F7PushNotificationHelper {
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setPriority(priority)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.setContentIntent(pending)
.build()
manager.notify((System.currentTimeMillis() % Int.MAX_VALUE).toInt(), notification)
@@ -5,6 +5,8 @@
<string name="call_action_accept">Принять</string>
<string name="call_action_decline">Отклонить</string>
<string name="incoming_call_subtitle">F7cloud звонок</string>
<string name="call_missed_title">Пропущенный звонок</string>
<string name="call_missed_public">Вы пропустили звонок</string>
<plurals name="call_queue_waiting">
<item quantity="one">Ещё %d звонок в очереди</item>
<item quantity="few">Ещё %d звонка в очереди</item>
-3
View File
@@ -1,3 +0,0 @@
plugins {
id 'java-library'
}
+71
View File
@@ -0,0 +1,71 @@
# CI — Gitea Actions
Пайплайн: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). Gitea на git.f7cloud.ru — **1.26.1**
(Actions поддерживаются).
## Что делает
| Триггер | Джоба | Действия |
|---|---|---|
| push в `main`, любой PR | `verify` | `testDebugUnitTest` + `:app:lintRelease` + `:app:assembleDebug` (+ артефакт debug-APK) |
| тег `v*` | `release` | подписанный `assembleRelease` + архив исходников (GPL §6) → Gitea Release |
## Предпосылка: self-hosted раннер (инфра ячейки B)
Проект тяжёлый (vendor talk-android + WebRTC), поэтому раннер — **self-hosted** с тёплым
Gradle-кэшем, а не контейнер (иначе WebRTC качается каждый прогон). Требования к машине раннера —
как у билд-машины: **JDK 21** (`/usr/lib/jvm/java-21-openjdk-amd64`) + **Android SDK 36**
(`/opt/android-sdk`, platform android-36, build-tools 36.0.0).
Регистрация — **repo-токен** из Gitea → репозиторий `root/f7cloud_mobile` → Settings → Actions →
Runners → Create new Runner (НЕ site-admin токен — тот регистрирует раннер общеинстансно и он
подхватывает джобы чужих ячеек; проверено — брал `glb_adm/f7_talk`). Метки `self-hosted`+`f7-android`
соответствуют `runs-on: [self-hosted, f7-android]`.
**Развёрнуто на инфре B (2026-07-09):** `act_runner` v0.2.11 + Node 20 (`/opt/nodejs`) + Docker
(`docker.io`, нужен act_runner для старта, но джобы идут на **хосте** — метки `:host`, тёплый кэш
`/root/.gradle`). Конфиг `/opt/act_runner/config.yaml` (labels `self-hosted:host`,`f7-android:host`,
`container.docker_host: "-"`). systemd-сервис `act_runner.service` (enabled, Restart=always,
env JAVA_HOME/ANDROID_HOME/PATH+node). Регистрация — repo-scope (только f7cloud_mobile).
```bash
systemctl status act_runner # состояние
journalctl -u act_runner -f # логи
```
## Secrets репозитория (Gitea → Settings → Actions → Secrets)
Для джобы `release`:
- `F7_KEYSTORE_BASE64` — боевой keystore, `base64 -w0 release.jks`
- `F7_KEYSTORE_PASSWORD`, `F7_KEY_ALIAS`, `F7_KEY_PASSWORD` — см. [RELEASE-SIGNING.md](RELEASE-SIGNING.md)
## GPL corresponding source
`scripts/package-source.sh` собирает архив всего исходника (вкл. vendor/talk-android GPL, LICENSE,
NOTICE) — обязательство GPLv3 §6. Работает и вручную:
```bash
scripts/package-source.sh dist/ # → dist/f7cloud-mobile-v<ver>-<rev>-source.tar.gz
```
В CI прикладывается к каждому Gitea Release. До появления раннера — публиковать исходник
вручную этим скриптом к каждой распространяемой сборке.
## Статус — ⏸️ ОТЛОЖЕНО (2026-07-09)
Раннер развёрнут на инфре B (act_runner 0.2.13 + Node 20 + Docker + systemd), диспетчеризация
Gitea работает (джобы доходят до раннера). **НО host-исполнение act_runner 0.2.13 в нашем
окружении сломано**: криво резолвит путь к шагам и JS-экшенам —
`No such file or directory` на `.cache/act/<id>/act/workflow/0.sh` и `MODULE_NOT_FOUND` на
`actions/checkout` (задвоение `hostexecutor/.cache/act`). Обойти на уровне workflow нельзя
(ломается сам запуск шага). Workflow **запаркован** (триггер `workflow_dispatch`), сервис
раннера остановлен и отключён от автозапуска — чтобы push'и не плодили падающие прогоны.
**Рабочий путь (когда вернёмся):** Docker-исполнение вместо host —
- кастомный образ с JDK 21 + Android SDK 36 (в дефолтном ubuntu их нет);
- volume для тёплого `~/.gradle` (vendor/WebRTC);
- раннер с меткой на docker-образ (не `:host`); в docker-режиме пути корректны, JS-экшены работают;
- регистрировать repo-токеном именно из **`f7cloud_mobile`** (id 24) — при отладке «repo-токены»
ошибочно регистрировали к чужому `f7cloud_client` (id 4) или в user-scope; в Gitea осталось
~5 дублей раннера `f7-b-android` (удалить в админке).
**Пока:** сборки и релизы — **вручную** на билд-машине (боевой ключ владельца из
`/root/.f7cloud-keys/`), исходники — `scripts/package-source.sh`, Release в Gitea — через API.
Что и делаем: релиз `v0.5.123` опубликован (APK + source) вручную.
+49
View File
@@ -0,0 +1,49 @@
# Источник дизайна — Figma (попиксельная мобильная вёрстка)
Задача: мобильный клиент = мобильная версия F7 попиксельно. Источник истины — **Figma роли `design`**
(не боевой forbion; glb устарел). Координация — mail/044 (через integrator-b). Приоритет:
**Файлы → Почта → Конференции**, референс **360dp**, тема светлая.
## Доступ к Figma API
Токен — вне git: `/root/.f7cloud-keys/figma.env` (`FIGMA_TOKEN`, `FIGMA_FILE=ocVUCYCrfFUoqYYvlcLPBF`).
```bash
source /root/.f7cloud-keys/figma.env
curl -H "X-Figma-Token: $FIGMA_TOKEN" "https://api.figma.com/v1/files/$FIGMA_FILE/nodes?ids=<ID>&depth=2"
```
⚠️ **Rate limit:** глубокие сканы всей страницы (`1:90` depth≥3) ловят **429**. Работать ТОЧЕЧНО —
запрашивать конкретные node ID нужного экрана с малой глубиной. Рендер кадра в PNG:
`GET /v1/images/$FIGMA_FILE?ids=<ID>&format=png&scale=2`.
## Карта файла (страницы)
| ID | Страница |
|---|---|
| `1:90` | 🟢 Дизайн облачного рабочего места — ОСНОВНОЙ дизайн (65 секций по модулям) |
| `0:1` | Брендбук (токены: TYPOGRAPHY 1:116, цвета 97:8269) |
| `2095:46379` | ⚫️ Пробная тёмная тема (на будущее) |
Мобильные экраны — кадры **360px** (полноэкранные ~360x800), разбросаны по секциям модулей и по
верхнему уровню `1:90`. Секции: Файлы `17:4427`/`948:65419`, Почта `97:16912`/`1609:71537`,
Конференции/Встречи `4:1166`, Конференция `1002:67493`. NB: ссылка владельца `1327:29454`
ДЕСКТОП (1440), не мобиль.
## Снятые токены (сверено с Figma)
**Цвета** — совпадают с текущим `F7Colors` (палитра снята точно ранее): Primary `#70B62B`,
Background `#FBFBFB`, Surface `#FFFFFF`, SurfaceMuted `#F5F5F5`, TextPrimary `#151515`,
TextSecondary `#808080`, Border `#E6E6E6`, PrimaryLight `#F1F8EA`.
**Типографика** — шрифт **Raleway** (medium 500 / semibold 600), шкала 14/18/20/24px
(lh 20/22/24/26). В `F7Typography` есть база; точные размеры конкретных экранов сверяются поэкранно.
**Spacing/радиусы** — раньше НЕ было токенов (аудит: ~1087 инлайновых .dp). Заведены
`F7Spacing` (4dp-сетка) + `F7Radius`. Точные значения экрана — с макета.
## Процесс попиксельной вёрстки (на экран)
1. Найти node ID мобильного экрана (360) в Figma, отрендерить PNG-эталон (scale=2).
2. Извлечь измерения: размеры/паддинги/цвета/текст-стили дочерних узлов (точечный nodes-запрос).
3. Свести к токенам (`F7Colors`/`F7Spacing`/`F7Typography`), реализовать в Compose.
4. Верификация side-by-side: PNG Figma ↔ скриншот приложения (владелец — физ.телефон + BlueStacks;
universal debug-APK со всеми ABI готов, дефолт-URL glb).
+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 | 41.3 | предв. | ~29 | ? | нет | P1 меню сообщения ~закрыто (Копировать/ссылка/2×переслать/Напоминание/Ответить-лично, …01198a4); остаток P1 файл-тред+перевод эскалирую (кросс-модуль/провайдеры); далее P2 меню беседы; Fd на устройстве |
| tasks | b-mob-tasks | 1 | 24.6 | 52.7 | ~34 | ~8 | нет | P1 закрыт в коде (умные коллекции+3-state видимость+персист, `bd56c4b`); далее P2 управление списками; ждёт девайс-сверки |
| notes | b-mob-notes | 1 | 42.8 | 0.0 | 26 | 21 | нет | P1 read-only ядро в коде+UI (`8a8f1cf` данные + `fb120d5` VM/список/редактор/конфликт); compile+7/7 тестов+detekt OK (worktree); Fd ждёт девайс. Пинг лиду на регистрацию экрана в app-шелле |
| notifications | b-mob-notify | 0 | — | — | — | — | нет | карта готова, diff |
| deck | b-mob-deck | 1 | 23.1 | предв. | 51 | ? | нет | P1: «Назначить мне/снять» (6eeba1b) + бейдж комментариев (9d726f4) done, green compile; далее бейдж чек-пунктов, меню колонки (Удалить/Переименовать), быстрые сроки; Fd на устройстве |
| calendar | b-mob-calendar | 1 | 64.0 | 80.4 | ~35 | ~7 | нет | P1 разворот повторений+BYDAY=1MO ЗАКРЫТ в коде (`CalendarRecurrence`, 15 тестов, compile+detekt green, коммиты e7d0a5a/492af61); Appointments=n-a (101); далее P2 EXDATE/RECURRENCE-ID |
| contacts | b-mob-contacts | 1 | 31.2 | предв. | ~37 | ? | нет | P1 ядро edit/delete в main (`bc1a497`: updateContact/deleteContact If-Match + VCardEditor, 10 тестов, гейт зелёный); Команды=n-a (102); далее шаг 2/2 Room+форма, потом P2 группы/типы |
Обозначения статуса: `карта``diff` (итерация 1) → `правки``повторный прогон`
`✅ 0` (Ff=Fd=100%) → `✅ 0 подтв.` (второй прогон держит 0).
+168
View File
@@ -0,0 +1,168 @@
# Diff: Календарь (Calendar) — итерация 1 (2026-07-13)
Ff_app: **64.0%** · Fd_app: **80.4%** (среднее по 5 экранам, невзвешенно) · n-a: Экран 5
Appointments (решение лида mail/101 — организаторская фича, мобильно-неприменима, вне знаменателя).
> **P1 закрыт в коде** (итерация правок 1): разворот повторяющихся событий реализован —
> `CalendarRecurrence` (генератор оккуренций RRULE на лету, без кэша и без `<c:expand>`,
> mail/072) + `eventOccursOnDay` теперь RRULE-осознан; +11 юнит-тестов
> (`CalendarRecurrenceTest`, зелёные), `:feature:calendar:compileReleaseKotlin`+`detekt` green.
> Ждёт девайс-сверки (подтверждающий второй прогон, mail/084).
Сверка веб (`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 100% · Fd 75.0%
Ф (10, open 0):
- [Ф][done] предыдущий/следующий период · [Ф][done] «Сегодня»
- [Ф][done] переключатель вида: День/Неделя/Месяц/**Год/Список** (все 5, `CalendarViewMode`)
- [Ф][done] «Создать событие» · [Ф][done] открыть навигацию (бургер)
- [Ф][done] тап по дню → выбор дня · [Ф][done] тап по событию → открыть
- [Ф][done] «Показать незапланированные задачи» (`loadUnscheduledTasks`)
- [Ф][done] **разворот повторяющихся событий в сетке** — реализован: `CalendarRecurrence`
генерирует оккуренции RRULE (DAILY/WEEKLY+BYDAY/MONTHLY/YEARLY, INTERVAL, UNTIL/COUNT) на
лету, `eventOccursOnDay` теперь RRULE-осознан → 2-я и дальше оккуренции видны в сетке/списке/
дне (долг №2 закрыт в коде; +11 юнит-тестов). Ждёт девайс-сверки.
Д (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 / Расписание встреч — `n-a` (вне метрики, mail/101)
Решение лида: организаторская фича (публикация слотов, внешние букинги) — десктоп/веб-воркфлоу,
на мобильном клиенте неприменима, тянет тяжёлый UI. Экран целиком `n-a`, из знаменателя исключён,
остаётся в финальном списке с обоснованием.
Ф (10, все `n-a`) — раздел отсутствует в нативе (осознанно):
- [Ф][open] список расписаний встреч · [Ф][open] «Создать расписание встреч»
- [Ф][open] поля: имя/описание/местоположение/длительность/инкремент
- [Ф][open] «Календарь бронирования» · [Ф][open] «Календари для проверки конфликтов»
- [Ф][open] буферы до/после · [Ф][open] макс. слотов в день / горизонт бронирования
- [Ф][open] «Создать комнату Talk» для встречи · [Ф][open] публичная страница бронирования
(внешний гость: выбор слота → Подтвердить)
Д (n/a пока — экран не реализован).
> Решено (mail/101): `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): ✅ **СДЕЛАНО в коде**
(`CalendarRecurrence` + `eventOccursOnDay`, mail/072-совместимо: развёртка на лету, без кэша
и `<c:expand>`; +11 тестов). ✅ **`BYDAY=1MO` тоже починен** (коммит 492af61): `RecurrenceRule`
хранит/сериализует ординал (раньше терялся в `toRrule`) + разворот n-го дня месяца +4 теста.
Остаётся девайс-сверка (подтверждающий прогон).
- **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`):_ виды Год/Список,
корзина, незапланированные задачи, ответы на приглашения (Принять/Может быть/Отклонить).
+144
View File
@@ -0,0 +1,144 @@
# Diff: Контакты (CardDAV) — итерация 1 (2026-07-13)
Ff_app: **31.2%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 8
> Прогресс P1 (коммит bc1a497): ЯДРО правки/удаления готово и покрыто тестами (10 юнит-тестов
> `VCardEditorTest` зелёные, гейт `:app:compileReleaseKotlin` зелёный) — `CardDavClient.updateContact`
> (PUT If-Match, round-trip неизвестных полей vCard) / `deleteContact` (DELETE If-Match) +
> `VCardEditor`. UI-часть (Room href/etag/raw + форма правки + delete в карточке) — шаг 2/2, следующим
> коммитом; пункты «Править»/«Удалить» карточки станут `[done]` после неё.
> Решение лида (mail/102): экран «Команды» (Circles) — целиком `n-a` (отдельный тяжёлый апп, не контакты);
> порядок P1→P2→P4→P3→P5 утверждён.
Сверка веб (карта `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: Команда (Circles/Teams) — n-a целиком (0 применимых Ф; n-a 8) — исключён из среднего
Решение лида (mail/102): отдельного экрана команд в мобильных Контактах v1 НЕ делаем — Circles это
самостоятельный тяжёлый апп (членство/уровни/шары), не контакты. Весь экран `n-a`:
- [Ф][n-a] Просмотр команды (название/владелец/участники).
- [Ф][n-a] «✏️ Править» команду.
- [Ф][n-a] «Копировать ссылку».
- [Ф][n-a] «Добавить» участника.
- [Ф][n-a] Список участников.
- [Ф][n-a] Создать «Папка» — зона `feature/files`.
- [Ф][n-a] Создать «Конференции» — зона `feature/talk`.
- [Ф][n-a] Создать «Календарь» — зона `feature/calendar`.
> Обоснование: Circles = отдельное приложение (мобильно — не в скоупе Контактов v1); три «Создать …»
> — кросс-модульные создающие потоки чужих модулей. NB: «команды» ≠ «группы контактов»: группы
> (CATEGORIES→чипы) остаются в скоупе (P2).
## Экран 7: Параметры контактов (модальный шит) — Ff 0.0% (5 open Ф / 5)
Отсутствует в нативе:
- [Ф][open] «Сортировать по» (Имя/Фамилия/фонетика ×2/Отображаемое имя/Последнее изменение).
- [Ф][open] «Импорт контактов».
- [Ф][open] Адресные книги: список (Контакты / Недавние / Учётные записи).
- [Ф][open] Адресные книги: ⋯ (переименовать/удалить/шаринг книги).
- [Ф][open] «Новая адресная книга».
---
## Сводка и приоритеты (прошу приоритизацию у лида)
Ff_app **31.2%** по 6 экранам (Э1 100 · Э2 18.2 · Э3 42.1 · Э4 26.7 · Э5 0 · Э7 0; Э6 Команды = n-a, исключён). 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) — РЕШЕНО (mail/102)
- ✅ Порядок P1→P2→P4→P3→P5 — утверждён.
- ✅ **Команды (Circles/Teams)** (Экран 6) — `n-a` целиком (учтено выше).
- ⏳ **Оргструктура** (Экран 5) — лид пока не высказался; держу `open` (не занижаю знаменатель
односторонним n-a). Аналогична Circles по «тяжести» — вероятный кандидат в 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;
на реальном телефоне панель видна.
+167
View File
@@ -0,0 +1,167 @@
# Diff: Карточки (Deck) — итерация 1 (2026-07-13)
Ff_app: **23.1%** (51 open Ф / 76) · Fd_app: **предварительно** (веб-вёрстка сверена live через
iframe 390px; натив-дизайн-проход на устройстве — след. итерация) · n-a: 4
> Прогресс P1: (6eeba1b) меню карточки «**Назначить мне / Снять назначение с меня**»
> (`assignUser`/`unassignUser`, type 0); (9d726f4) **бейдж числа комментариев** «💬 N» на карточке
> (`commentsCount`) — оба `[done]` ниже (Ff экрана 4: 52.0%→60.0%). Green compile против core;
> detekt модуля без новых замечаний (baseline-рефреш — лид, mail/095).
Сверка веб (карта `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 60.0% (10 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) — натив не показывает.
- [Ф][done] Бейдж «**комментариев**» «💬 N» — реализовано (`commentsCount`); индикатор M
непрочитанных — Д-нюанс на след. проход.
- [Ф][done] Аватары назначенных — есть (отображение).
**Всплывающее меню карточки (⋯ / long-press, `CardMenuEntries`)** — в нативе overflow-меню НЕТ
(тап открывает лист; часть действий — кнопками листа):
- [Ф][done] «**Сведения о карточке**» — тап открывает лист-деталь.
- [Ф][done] «**Изменить заголовок**» — в листе.
- [Ф][done] «**Назначить мне / Снять назначение с меня**» — реализовано (кнопка в листе-детали,
`toggleAssignSelf`; назначение ПРОИЗВОЛЬНОГО пользователя — отдельный пункт, экран 5).
- [Ф][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/Колонка/Карточка) | 60.0% | 10/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 (среднее)** | **23.1%** | **51/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) считаю по чек-листу «элементы+функции на экран» — жду формулу лида.

Some files were not shown because too many files have changed in this diff Show More