49 Commits

Author SHA1 Message Date
b-dev-mobile 05ca2b533a files: чипы-фильтры порядок+ё под моб.веб forbion (Тип файла/Изменён/Участники)
Сверено на эмуляторе против живой моб.вёрстки forbion (iframe 360px):
веб = Тип файла/Изменён/Участники; было натив = Тип файла/Участники/Изменен.
Исправлен порядок (Изменён перед Участники) и «Изменен»→«Изменён». Проверено на эмуляторе.
2026-07-13 12:32:30 +00:00
b-dev-mobile 195235495e F7SecureScreen: не ставить FLAG_SECURE в debug-сборке (FLAG_DEBUGGABLE-гейт)
Релиз остаётся защищённым (скриншоты/запись/превью блокируются), но debug-APK
теперь снимается на эмуляторе — нужно для автономного визуал-теста (screencap
на secure-окне возвращает пустой кадр). Проверка через ApplicationInfo.FLAG_DEBUGGABLE,
без BuildConfig (работает в library-модуле).
2026-07-13 11:57:33 +00:00
b-mob-notify 93086d3f43 docs(app-map): notifications Fd — зафиксировал ограничение iframe (multi-tab координация ленты уведомлений, populated-строки не рендерятся) 2026-07-13 10:58:51 +00:00
b-mob-notify 002f22bb91 docs(app-map): notifications Fd 0→20% — 2 Д закрыты лидом+верифицированы Roborazzi (кнопка/зазор), detekt зелёный 2026-07-13 10:42:28 +00:00
b-mob-notify 80725396c6 test(designsystem): перезапись Roborazzi-эталона строки уведомления после Fd-фикса лида (bfa870c) — кнопка «Присоединиться» больше не рвётся посреди слова 2026-07-13 10:40:57 +00:00
b-dev-mobile bfa870c021 designsystem: detekt зелёный + Fd-фикс кнопок уведомления (mail/114)
- detekt --auto-correct: NoMultipleSpaces/ImportOrdering/NoUnusedImports/spacing
  по F7Colors/F7Components/F7MobileBottomBar/F7AppMenuSheet/F7Typography (был красный на main, 18)
- F7NotificationActionUi вынесен в свой файл (fix MatchingDeclarationName)
- NotificationActionButton: подпись 1.5× вместо 2× (на w360dp удвоенный кегль рвал слово
  «Присоединитьс/я»; forbion не переносит), maxLines=2, паддинги 16/9
- зазор subject↔message 6→10dp (сливались на 360dp)

Модуль detekt+compile green.
2026-07-13 10:37:57 +00:00
b-mob-notify 715bb5a056 docs(app-map): notifications Fd — Roborazzi-эталон снят, находки (перенос текста primary-кнопки на w360dp, зазор subject/message) 2026-07-13 10:28:59 +00:00
b-mob-notify 2854f5d5a4 test(designsystem): Roborazzi-эталон F7NotificationRow (Fd, w360dp) — состояния с действиями/без; PNG-эталон нативной строки уведомления 2026-07-13 10:28:02 +00:00
b-mob-notify eed7723230 docs(app-map): notifications P1 ЗАКРЫТ (лента a30627a + unregister cc7ad71/255c962 + retry a6bda89), Ff 45.5→63.6%; Fd на Roborazzi 2026-07-13 10:22:13 +00:00
b-dev-mobile 255c962d8f logout: снятие устройства с f7push (unregisterBlocking) best-effort на IO, рядом с ревокацией app-password (mail/112а) 2026-07-13 10:15:12 +00:00
b-dev-mobile a30627a07d notifications P1: лента с действиями + «Скрыть все»
- NotificationsRepository: парс actions[] (label/link/type/primary),
  dismissAll (DELETE /api/v2/notifications), executeAction (GET/POST/PUT/DELETE по action.link)
- F7NotificationRow: FlowRow кнопок-действий (primary=заливка, прочие=контур)
- NotificationsSheet: заголовок «Уведомления» + «Скрыть все», проброс действий,
  оптимистичное скрытие после действия/скрыть-все

Контракты сверены с исходником notifications на forbion (deleteAllNotifications, actionToArray).
2026-07-13 10:11:32 +00:00
b-mob-notify 5f81bd92f4 docs(app-map): notifications — unregister клиент DONE (cc7ad71), logout-вызов патчем лиду (112); Fd ждёт feature/notifications 2026-07-13 10:10:28 +00:00
b-mob-notify cc7ad71d41 push(core): F7PushRegistrar.unregisterBlocking — снятие устройства f7push при logout (DELETE devices/{id}, контракт mail/110); compile+detekt green 2026-07-13 10:06:03 +00:00
b-mob-deck 8a1eba3725 app-map: deck.diff — Fd-пайплайн Roborazzi поднят + Д-находки 2 экранов (бейджи иконки-vs-эмодзи, аватар исполнителя, колонка Участники); STATUS 2026-07-13 09:46:38 +00:00
b-mob-deck 58f11c2991 test(deck): Roborazzi Fd screenshot-тесты (360dp) — список досок + доска со стеком (mail/111)
Обвязка build.gradle (плагин roborazzi + testOptions + тест-деп), DeckScreenshotTest рендерит
DeckBoardRow/DeckStackSection на мок-данных bmob-* (метки/срок/чек-пункты/комментарии/исполнитель),
PNG в src/test/screenshots. DeckBoardRow/DeckStackSection private→internal для рендера из теста.
Проверено: recordRoborazziDebug зелёный, detekt green.
2026-07-13 09:41:13 +00:00
b-dev-mobile 9efb3595c0 docs(app-map): конвенция эталонных веб-скринов forbion для Fd (360dp, iframe-техника, приватность) 2026-07-13 09:32:13 +00:00
b-mob-notify fa48569dc1 docs(app-map): notifications diff/STATUS — P2-push retry DONE, unregister блокирован f7push-эндпоинтом (mail/110) 2026-07-13 09:29:17 +00:00
b-mob-notify a6bda894b0 push(core): P2 retry/бэкофф регистрации устройства (транзиентные сбои/5xx/408/429) + фикс detekt ArgumentListWrapping в F7IncomingCallQueue 2026-07-13 09:28:30 +00:00
b-dev-mobile 1acc604bd5 test(designsystem): Roborazzi render 360dp (Figma-фрейм), не 390 — эталон пересnят 2026-07-13 09:22:06 +00:00
b-dev-mobile 333026143b test(designsystem): Roborazzi screenshot-каркас — рендер Compose в PNG на JVM (Fd-сверка без эмулятора)
Пилот: F7PrimaryButton/F7SecondaryButton рендерятся в PNG (390dp xxhdpi) через
Robolectric+Roborazzi, recordRoborazziDebug зелёный. Основа автономного Fd-прохода:
экран → PNG → сравнение с веб-мобильной forbion. Роли добавляют тесты на свои экраны.
2026-07-13 09:19:32 +00:00
b-mob-contacts cc602abc0d docs(app-map): P2 шаг1 типы TEL/EMAIL отражён в diff/STATUS (a8741e9) 2026-07-13 08:58:52 +00:00
b-mob-contacts a8741e982c feat(contacts): P2 шаг1 — типы TEL/EMAIL (парсинг из raw, подписи в карточке, сохранение при правке) +4 теста 2026-07-13 08:57:34 +00:00
b-mob-deck 559a35aeb3 app-map: deck.diff — бейдж чек-пунктов [done] (Ff_app 25.3%), STATUS 2026-07-13 08:48:46 +00:00
b-mob-deck eff3f1c843 feat(deck): бейдж чек-пунктов «☑ x/total» на карточке (P1 diff — CardBadges)
deckChecklistCounts парсит чекбоксы описания (regex как веб). Green compile+detekt.
2026-07-13 08:47:25 +00:00
b-mob-notify c31adb5692 app-map: notifications.diff.md (Ff 45.5%, iter1) + строка STATUS — снимаю гейт сборки 2026-07-13 08:46:57 +00:00
b-mob-deck a4a3ff1717 app-map: deck.diff — быстрые опции срока [done] (Ff_app 24.8%), STATUS 2026-07-13 08:11:48 +00:00
b-mob-deck d0bf3590d1 feat(deck): быстрые опции срока (Сегодня/Завтра/Выходные/Неделя) в карточке (P1 diff)
DeckDueChip + deckQuickDueIso (полночь UTC, как пикер). Green compile+detekt (без новых замечаний).
2026-07-13 08:10:19 +00:00
b-dev-mobile e6570c008e chore(core:network): refresh detekt-baseline — амнистия смеллов из пакета v0.5.144 (CalDavClient free-busy/shares, NextcloudWebSession); мой легаси-долг, не calendar/contacts 2026-07-13 08:06:41 +00:00
b-dev-mobile ddd9fd2a33 feat(app): нативный экран Заметок в оболочке (регистрация по пингу b-mob-notes mail/104)
AppTab.Notes + пункт шторки «Заметки» → NotesScreen (было веб-ссылка /apps/notes/).
Проверено компиляцией :app в чистом worktree origin/main.
2026-07-13 08:02:35 +00:00
b-mob-contacts 09b091af01 docs(app-map): P1 edit/delete закрыт в коде — Ff 31.2→37.4% (Э3 Править/Удалить, Э4 форма done) 2026-07-13 08:01:58 +00:00
b-mob-deck 2567273843 app-map: deck.diff — меню колонки Переименовать/Удалить [done] (Ff_app 24.1%), STATUS 2026-07-13 08:00:47 +00:00
b-mob-deck 4fdadef16b deck: вынос DeckStackDialogs (под LongMethod) + refresh detekt-baseline модуля (v0.5.144-долг, как calendar e7d0a5a) — detekt GREEN 2026-07-13 07:59:20 +00:00
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
64 changed files with 3343 additions and 451 deletions
+5
View File
@@ -2,7 +2,12 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — типы TEL/EMAIL (P2, шаг 1).** `VCardEditor.parseTypedValues` (+публичный делегат `CardDavClient.parseTypedValues`) извлекает значения EMAIL/TEL с токеном TYPE (WORK/HOME/CELL), игнорируя технические INTERNET/VOICE/PREF; разбор из уже сохранённого `raw` vCard — **без новой Room-миграции**. `ContactItem.emailEntries`/`phoneEntries` (фолбэк на значения без типа). Карточка: подписи полей по типу («Рабочий»/«Личный»/«Мобильный»/«Домашний»/…, `ContactUi.emailTypeLabel`/`phoneTypeLabel`) вместо «Email 2»/«Телефон 2». Форма правки: типы сохраняются по значению (`emailTypeByValue`/`phoneTypeByValue`) — **исправлен P1-каветат** нормализации типов к INTERNET/CELL при правке. +4 юнит-теста (`VCardEditorTest`, всего 14): парсинг типов, игнор технических токенов, пустой тип, round-trip сохраняет типы. Гейты зелёные (`:app:compileReleaseKotlin`, `:feature:contacts:detekt`+compile). Осталось по P2: селектор типа в форме + группы CATEGORIES. | контракты: core `CardDavClient` +1 публичный метод (обратно совместимо); feature-слой изолирован | низкий: чтение из raw + подписи, миграций/схемы нет; на устройстве не гонялось (юниты + компил-гейт)
- 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. | контракты не менялись | низкий
@@ -18,6 +18,7 @@ enum class AppTab(
Talk("Конференции", "spreed-header-icon.svg", "spreed-glass.svg"),
Deck("Карточки", "deck-header-icon.svg", "deck-glass.svg"),
Tasks("Задачи", "task-header-icon.svg", "task-glass.svg"),
Notes("Заметки", "notes-header-icon.svg", "notes-glass.svg"),
Support("Поддержка", "icon-header-f7support.svg", "icon-header-f7support.svg"),
;
@@ -43,7 +44,7 @@ private val coreAppMenuEntries = listOf(
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
AppMenuEntry(AppTab.Notes, "Заметки", "notes"),
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
)
@@ -79,6 +79,7 @@ import ru.forbion.f7cloud.feature.deck.DeckScreen
import ru.forbion.f7cloud.feature.files.FilesScreen
import ru.forbion.f7cloud.feature.f7support.SupportScreen
import ru.forbion.f7cloud.feature.mail.MailScreen
import ru.forbion.f7cloud.feature.notes.NotesScreen
import ru.forbion.f7cloud.feature.tasks.TasksScreen
import ru.forbion.f7cloud.feature.talk.TalkScreen
import ru.forbion.f7cloud.mobile.navigation.AppLinkResolver
@@ -574,6 +575,11 @@ fun AppScaffold(
onOpenListConsumed = { pendingTasksListSlug = null },
onUnauthorized = forceLogout,
)
AppTab.Notes -> NotesScreen(
session = currentSession,
modifier = contentModifier,
onUnauthorized = forceLogout,
)
AppTab.Support -> SupportScreen(
session = currentSession,
modifier = contentModifier,
@@ -59,9 +59,11 @@ 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.F7NotificationActionUi
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
import ru.forbion.f7cloud.core.network.F7Notification
import ru.forbion.f7cloud.core.network.F7NotificationAction
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NotificationsRepository
import ru.forbion.f7cloud.core.network.UnauthorizedException
@@ -337,6 +339,53 @@ fun NotificationsSheet(
}
}
fun dismissAllNotifications() {
items = emptyList()
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
repository.dismissAll(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
}
}.onFailure { t ->
if (t is UnauthorizedException) {
onDismiss()
onUnauthorized()
} else {
loadNotifications()
}
}
}
}
fun runAction(notification: F7Notification, action: F7NotificationAction) {
items = items.filter { it.id != notification.id }
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
repository.executeAction(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
action = action,
trustAllCerts = session.trustAllCerts,
)
}
}.onFailure { t ->
if (t is UnauthorizedException) {
onDismiss()
onUnauthorized()
} else {
loadNotifications()
}
}
}
}
F7FloatingPanel(
visible = visible,
onDismiss = onDismiss,
@@ -395,24 +444,65 @@ fun NotificationsSheet(
else -> Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
.fillMaxHeight(),
) {
items.forEachIndexed { index, item ->
F7NotificationRow(
subject = item.subject,
message = item.message,
relativeTime = formatNotificationRelativeTime(item.datetime),
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
closeIconUrl = closeIconUrl,
authHeader = authHeader,
onClick = {
onDismiss()
onNotificationClick(item)
},
onDismiss = { dismissNotification(item) },
showDivider = index < items.lastIndex,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Уведомления",
style = MaterialTheme.typography.titleMedium.copy(
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
TextButton(onClick = { dismissAllNotifications() }) {
Text(
"Скрыть все",
style = MaterialTheme.typography.labelLarge.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize * 2,
lineHeight = MaterialTheme.typography.labelLarge.lineHeight * 2,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = F7Colors.Primary,
)
}
}
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
items.forEachIndexed { index, item ->
F7NotificationRow(
subject = item.subject,
message = item.message,
relativeTime = formatNotificationRelativeTime(item.datetime),
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
closeIconUrl = closeIconUrl,
authHeader = authHeader,
onClick = {
onDismiss()
onNotificationClick(item)
},
onDismiss = { dismissNotification(item) },
showDivider = index < items.lastIndex,
actions = item.actions.map {
F7NotificationActionUi(label = it.label, primary = it.primary)
},
onActionClick = { actionIndex ->
item.actions.getOrNull(actionIndex)?.let { runAction(item, it) }
},
)
}
}
}
}
@@ -5,11 +5,14 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.push.F7PushRegistrar
import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
@@ -29,9 +32,16 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
fun logout() {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
// Best-effort ревокация app password + снятие устройства с push ДО очистки локальной сессии.
_session.value?.let { current ->
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
F7PushRegistrar.unregisterBlocking(getApplication(), current)
}
}
}
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
+1
View File
@@ -7,6 +7,7 @@ plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.0' apply false
id 'org.jetbrains.kotlin.plugin.parcelize' version '2.3.0' apply false
id 'com.google.devtools.ksp' version '2.3.4' apply false
id 'io.github.takahirom.roborazzi' version '1.42.0' 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
}
@@ -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 }
}
+17
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
id 'io.github.takahirom.roborazzi'
}
android {
@@ -22,6 +23,12 @@ android {
kotlinOptions {
jvmTarget = '17'
}
// Roborazzi/Robolectric: рендер Compose в PNG на JVM (Fd-сверка без эмулятора).
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
@@ -32,4 +39,14 @@ dependencies {
implementation libs.compose.material3
implementation libs.coil.compose
implementation libs.coil.svg
// Screenshot-тесты (JVM)
testImplementation composeBom
testImplementation libs.junit
testImplementation libs.robolectric
testImplementation libs.roborazzi
testImplementation libs.roborazzi.compose
testImplementation libs.roborazzi.junit.rule
testImplementation libs.compose.ui.test.junit4
debugImplementation libs.compose.ui.test.manifest
}
@@ -13,15 +13,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -8,18 +8,18 @@ import androidx.compose.ui.graphics.Color
*/
object F7Colors {
// --- 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 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)
@@ -5,52 +5,52 @@ 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.Spacer
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.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.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.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.VisualTransformation
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
@@ -7,9 +7,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -44,6 +44,7 @@ private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
private val BottomBarWidth = 328.dp
private val BottomBarHeight = 61.dp
private val BottomBarButtonSize = 55.dp
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
private val BottomBarIconSize = 25.dp
private val BottomBarSectionIconSize = 25.dp
@@ -0,0 +1,7 @@
package ru.forbion.f7cloud.core.designsystem
/** Кнопка-действие уведомления (label/link/type/primary) для рендера в строке. */
data class F7NotificationActionUi(
val label: String,
val primary: Boolean,
)
@@ -1,15 +1,20 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -25,13 +30,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import okhttp3.Credentials
private fun TextStyle.doubled(): TextStyle = copy(
fontSize = fontSize * 2,
lineHeight = lineHeight * 2,
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun F7NotificationRow(
subject: String,
@@ -44,6 +49,8 @@ fun F7NotificationRow(
onDismiss: () -> Unit,
showDivider: Boolean,
modifier: Modifier = Modifier,
actions: List<F7NotificationActionUi> = emptyList(),
onActionClick: (Int) -> Unit = {},
) {
val context = LocalContext.current
Column(
@@ -131,11 +138,65 @@ fun F7NotificationRow(
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(start = 78.dp, top = 6.dp),
.padding(start = 78.dp, top = 10.dp),
)
}
if (actions.isNotEmpty()) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 78.dp, top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
actions.forEachIndexed { index, action ->
NotificationActionButton(
label = action.label,
primary = action.primary,
onClick = { onActionClick(index) },
)
}
}
}
}
if (showDivider) {
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
}
}
@Composable
private fun NotificationActionButton(
label: String,
primary: Boolean,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(22.dp)
val base = Modifier
.clip(shape)
.clickable(onClick = onClick)
val boxModifier = if (primary) {
base.background(F7Colors.Primary, shape)
} else {
base
.background(F7Colors.Surface, shape)
.border(BorderStroke(1.dp, F7Colors.Border), shape)
}
val labelStyle = MaterialTheme.typography.labelLarge.let { s ->
// Подпись действия — 1.5× (не 2×): на w360dp удвоенный кегль рвал длинные слова
// («Присоединитьс/я»); на вебе forbion кнопки не переносятся посреди слова.
s.copy(fontSize = s.fontSize * 1.5f, lineHeight = s.lineHeight * 1.5f)
}
Box(
modifier = boxModifier.padding(horizontal = 16.dp, vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = labelStyle,
fontWeight = FontWeight.SemiBold,
color = if (primary) F7Colors.TextOnPrimary else F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -1,6 +1,7 @@
package ru.forbion.f7cloud.core.designsystem
import android.app.Activity
import android.content.pm.ApplicationInfo
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -10,15 +11,23 @@ import androidx.compose.ui.platform.LocalContext
* Пока этот экран на виду окно помечено FLAG_SECURE: содержимое не попадает
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
*
* В debug-сборке (FLAG_DEBUGGABLE) флаг НЕ ставится иначе невозможен автономный
* визуал-тест на эмуляторе (screencap возвращает пустой кадр). Релиз всегда защищён.
*/
@Composable
fun F7SecureScreen() {
val context = LocalContext.current
DisposableEffect(Unit) {
val debuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
val window = (context as? Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (!debuggable) {
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (!debuggable) {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
}
@@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ru.forbion.f7cloud.core.designsystem.R
val RalewayFamily = FontFamily(
Font(R.font.raleway_medium, FontWeight.Medium),
@@ -14,14 +13,64 @@ val RalewayFamily = FontFamily(
)
val F7Typography = Typography(
displayLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
titleLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
titleMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
titleSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
bodyLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
bodyMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
bodySmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
labelLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
labelMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
labelSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp),
displayLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 28.sp
),
titleLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 20.sp
),
titleSmall = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
bodyLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 20.sp
),
bodyMedium = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
bodySmall = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp
),
labelLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelMedium = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp
),
labelSmall = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp
),
)
@@ -0,0 +1,77 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Roborazzi-эталон строки уведомления (`F7NotificationRow`) для Fd-сверки 1:1 с мобильной
* лентой forbion (mail/111, ширина Figma-фрейма w360dp). Рендерит ключевые состояния строки,
* которые сверяются с веб-эталоном `docs/app-map/reference/notifications/`:
* - с действиями (primary=заливка + контур), длинный сабж, с message;
* - без действий, короткий сабж, без message.
* Иконки (`iconUrl`/`closeIconUrl`) грузятся через Coil/сеть в Robolectric не отрисуются,
* это влияет только на растр иконки, не на компоновку/типографику (их и сверяем).
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w360dp-h800dp-xxhdpi")
class NotificationRowScreenshotTest {
@get:Rule
val composeRule: ComposeContentTestRule = createComposeRule()
@Test
fun notification_row_states() {
composeRule.setContent {
F7Theme {
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
// Строка с действиями + длинный сабж + message
F7NotificationRow(
subject = "Приглашение в беседу «Планёрка команды разработки F7»",
message = "Константин Девальд приглашает вас присоединиться к звонку.",
relativeTime = "5 минут назад",
iconUrl = null,
closeIconUrl = "",
authHeader = null,
onClick = {},
onDismiss = {},
showDivider = true,
actions = listOf(
F7NotificationActionUi(label = "Присоединиться", primary = true),
F7NotificationActionUi(label = "Отклонить", primary = false),
),
onActionClick = {},
)
// Строка без действий, короткий сабж, без message
F7NotificationRow(
subject = "Рекомендованное приложение: Формы",
message = "",
relativeTime = "49 минут назад",
iconUrl = null,
closeIconUrl = "",
authHeader = null,
onClick = {},
onDismiss = {},
showDivider = false,
actions = emptyList(),
onActionClick = {},
)
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/notification-row-states.png")
}
}
@@ -0,0 +1,50 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Пилот Roborazzi: рендер Compose designsystem в PNG на JVM (без эмулятора).
* Каркас Fd-сверки: экран PNG сравнение с веб-мобильной forbion.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w360dp-h800dp-xxhdpi")
class ScreenshotProofTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun designsystem_primary_button() {
composeRule.setContent {
F7Theme {
Column(
modifier = Modifier.fillMaxSize().background(Color.White).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("F7 design proof — Roborazzi")
F7PrimaryButton(text = "Сохранить", onClick = {})
F7SecondaryButton(text = "Отмена", onClick = {})
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/proof-primary-button.png")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+8 -1
View File
@@ -6,24 +6,30 @@
<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:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List&lt;DavCalendar&gt;</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:CalDavClient.kt$CalDavClient$ </ID>
<ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>MaximumLineLength:LoginFlowClient.kt$LoginFlowClient$ </ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$fun listCalendarShares(client: OkHttpClient, calendarHref: String): List&lt;DavSharee&gt;</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 parseFreeBusyResponse(xml: String): Map&lt;String, List&lt;BusyPeriod&gt;&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): List&lt;DavContact&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>NoUnusedImports:NextcloudWebSession.kt$ru.forbion.f7cloud.core.network.NextcloudWebSession.kt</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>SpacingBetweenDeclarationsWithComments:CalDavClient.kt$CalDavClient$// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.</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>
@@ -31,6 +37,7 @@
<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:CalDavClient.kt$CalDavClient$;</ID>
<ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID>
<ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID>
</CurrentIssues>
@@ -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 {
@@ -150,6 +179,62 @@ 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("Не удалось разобрать сохранённый контакт")
}
/** Типизированные значения EMAIL/TEL из сырого vCard (значение + токен TYPE). P2. */
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> =
VCardEditor.parseTypedValues(rawVCard, property)
/** Удаление контакта с 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)
@@ -199,7 +284,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 {
@@ -264,47 +349,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
}
}
@@ -313,6 +386,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')
@@ -484,3 +597,160 @@ 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
}
}
/**
* Извлекает типизированные значения свойства (EMAIL/TEL) из сырого vCard: значение + сырой
* токен TYPE (WORK/HOME/CELL/). Порядок как в vCard, пустые пропускаем. Для карточки и
* префилла формы правки (P2 типы). Нормализацию метки в подпись делает UI-слой.
*/
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> {
val prop = property.uppercase()
return groupLogicalLines(rawVCard)
.filter { propertyName(it).uppercase() == prop }
.mapNotNull { line ->
val colon = line.indexOf(':')
if (colon < 0) return@mapNotNull null
val value = unescapeText(line.substring(colon + 1).trim())
if (value.isBlank()) {
null
} else {
VCardTypedValue(value = value, type = extractType(line.substring(0, colon)))
}
}
}
private val typeParamPattern = Regex("(?i)TYPE=\"?([A-Za-z0-9,\\- ]+)\"?")
private fun extractType(header: String): String {
// vCard 3.0: TYPE=WORK / TYPE="WORK,VOICE"; vCard 2.1: ...;WORK;VOICE
val fromParam = typeParamPattern.find(header)?.groupValues?.get(1)
val raw = fromParam
?: header.substringAfter(';', "").split(';').firstOrNull {
it.isNotBlank() && !it.contains('=')
}
?: ""
// берём первый содержательный токен, игнорируя технические (INTERNET/VOICE/PREF)
val ignored = setOf("INTERNET", "VOICE", "PREF")
return raw.split(',').map { it.trim().uppercase() }
.firstOrNull { it.isNotBlank() && it !in ignored }
?: raw.split(',').firstOrNull()?.trim()?.uppercase().orEmpty()
}
private fun unescapeText(v: String): String =
v.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
// 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(",", "\\,")
}
@@ -1,8 +1,19 @@
package ru.forbion.f7cloud.core.network
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/** Кнопка-действие уведомления (NC notifications: actions[]={label,link,type,primary}). */
data class F7NotificationAction(
val label: String,
val link: String,
val type: String,
val primary: Boolean,
)
data class F7Notification(
val id: Long,
val subject: String,
@@ -11,6 +22,7 @@ data class F7Notification(
val link: String,
val app: String,
val icon: String = "",
val actions: List<F7NotificationAction> = emptyList(),
)
class NotificationsRepository {
@@ -64,6 +76,25 @@ class NotificationsRepository {
link = obj.optString("link"),
app = obj.optString("app"),
icon = obj.optString("icon"),
actions = parseActions(obj.optJSONArray("actions")),
)
}
return out
}
private fun parseActions(array: JSONArray?): List<F7NotificationAction> {
if (array == null) return emptyList()
val out = mutableListOf<F7NotificationAction>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val link = obj.optString("link")
val label = obj.optString("label")
if (link.isBlank() || label.isBlank()) continue
out += F7NotificationAction(
label = label,
link = link,
type = obj.optString("type").ifBlank { "GET" }.uppercase(),
primary = obj.optBoolean("primary", false),
)
}
return out
@@ -90,4 +121,56 @@ class NotificationsRepository {
}
}
}
/** «Скрыть все» — NC deleteAllNotifications (DELETE .../api/v2/notifications). */
fun dismissAll(
serverUrl: String,
username: String,
appPassword: String,
trustAllCerts: Boolean = false,
) {
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications"
val request = Request.Builder()
.url(url)
.delete()
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}")
}
}
}
/**
* Выполнить кнопку-действие уведомления. link абсолютный URL от сервера,
* type GET/POST/PUT/DELETE. После действия сервер сам гасит уведомление.
*/
fun executeAction(
serverUrl: String,
username: String,
appPassword: String,
action: F7NotificationAction,
trustAllCerts: Boolean = false,
) {
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
val empty = "".toRequestBody("application/json".toMediaType())
val builder = Request.Builder()
.url(action.link)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
when (action.type.uppercase()) {
"POST" -> builder.post(empty)
"PUT" -> builder.put(empty)
"DELETE" -> builder.delete()
else -> builder.get()
}
client.newCall(builder.build()).execute().use { response ->
if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}")
}
}
}
}
@@ -0,0 +1,184 @@
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"))
}
// --- P2: разбор типов TEL/EMAIL ---
@Test fun parses_email_and_phone_types() {
val raw = buildString {
append("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:t\r\nFN:X\r\n")
append("EMAIL;TYPE=WORK:work@x.ru\r\n")
append("EMAIL;TYPE=HOME:home@x.ru\r\n")
append("TEL;TYPE=CELL:+7900\r\n")
append("TEL;TYPE=WORK:+7495\r\n")
append("END:VCARD\r\n")
}
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
assertEquals(listOf("work@x.ru", "home@x.ru"), emails.map { it.value })
assertEquals(listOf("WORK", "HOME"), emails.map { it.type })
val phones = VCardEditor.parseTypedValues(raw, "TEL")
assertEquals(listOf("CELL", "WORK"), phones.map { it.type })
}
@Test fun ignores_technical_type_tokens() {
// INTERNET/VOICE/PREF — технические, берём содержательный токен
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:i\r\nFN:X\r\n" +
"EMAIL;TYPE=INTERNET,HOME:h@x.ru\r\n" +
"TEL;TYPE=VOICE,CELL:+7900\r\n" +
"END:VCARD\r\n"
assertEquals("HOME", VCardEditor.parseTypedValues(raw, "EMAIL").single().type)
assertEquals("CELL", VCardEditor.parseTypedValues(raw, "TEL").single().type)
}
@Test fun typeless_value_yields_blank_type() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nFN:X\r\nEMAIL:plain@x.ru\r\nEND:VCARD\r\n"
val e = VCardEditor.parseTypedValues(raw, "EMAIL").single()
assertEquals("plain@x.ru", e.value)
assertEquals("", e.type)
}
@Test fun round_trip_preserves_parsed_types() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:r\r\nFN:X\r\n" +
"EMAIL;TYPE=WORK:w@x.ru\r\nTEL;TYPE=HOME:+7495\r\nEND:VCARD\r\n"
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
val phones = VCardEditor.parseTypedValues(raw, "TEL")
val out = VCardEditor.applyEdits(raw, VCardFields(displayName = "X", emails = emails, phones = phones))
// типы не потеряны при правке (чиним P1-каветат нормализации)
assertTrue(out.contains("EMAIL;TYPE=WORK:w@x.ru"))
assertTrue(out.contains("TEL;TYPE=HOME:+7495"))
}
}
@@ -159,7 +159,12 @@ object F7IncomingCallQueue {
val prefs = prefs(context)
cancelTimeout(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
prefs.edit()
.remove(KEY_QUEUE)
.remove(KEY_ACTIVE_TOKEN)
.remove(KEY_ACTIVE_AT)
.remove(KEY_ACTIVE_CALL)
.apply()
}
}
@@ -17,6 +17,10 @@ object F7PushRegistrar {
private const val TAG = "F7PushRegistrar"
private const val PREFS = "f7push"
private const val KEY_DEVICE_ID = "device_id"
private const val MAX_ATTEMPTS = 3
// Паузы перед 2-й и 3-й попытками (мс); экспоненциальный бэкофф.
private val BACKOFF_MS = longArrayOf(1000L, 3000L)
fun getDeviceId(context: Context): String {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -41,10 +45,60 @@ object F7PushRegistrar {
.put("platform", "android")
.put("clientApp", "f7cloud-mobile")
.toString()
var lastCode = 0
for (attempt in 0 until MAX_ATTEMPTS) {
if (attempt > 0) {
val wait = BACKOFF_MS[(attempt - 1).coerceAtMost(BACKOFF_MS.size - 1)]
try {
Thread.sleep(wait)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return lastCode
}
}
lastCode = try {
postJson(endpoint, session, body)
} catch (t: Throwable) {
Log.w(TAG, "register attempt ${attempt + 1} failed", t)
0
}
if (!isRetryable(lastCode)) {
return lastCode
}
Log.w(
TAG,
"register attempt ${attempt + 1} got $lastCode, " +
if (attempt + 1 < MAX_ATTEMPTS) "retrying" else "giving up"
)
}
return lastCode
}
// Транзиентные сбои: сетевое исключение (0), ошибка сервера (5xx), 408/429.
// 2xx — успех, 4xx (кроме 408/429) — постоянная ошибка (кривой app-password/запрос), повтор не поможет.
private fun isRetryable(code: Int): Boolean {
return code == 0 || code >= 500 || code == 408 || code == 429
}
/**
* Снимает устройство с регистрации в f7push (при logout) ДО очистки локальной сессии.
* Контракт сервера (f7push v0.2.4, подтверждён integrator-b, mail/110):
* `DELETE /ocs/v2.php/apps/f7push/api/v1/devices/{deviceId}`, та же Basic-авторизация +
* `OCS-APIRequest`; идемпотентен (200 `success:true` даже если строки нет). Best-effort:
* ошибку глотаем (сеть могла отвалиться при выходе), возвращаем HTTP-код или 0.
*/
fun unregisterBlocking(
context: Context,
session: AuthSession,
): Int {
val deviceId = getDeviceId(context)
val endpoint = session.serverUrl.trimEnd('/') +
"/ocs/v2.php/apps/f7push/api/v1/devices/$deviceId"
return try {
postJson(endpoint, session, body)
deleteRequest(endpoint, session)
} catch (t: Throwable) {
Log.w(TAG, "register failed", t)
Log.w(TAG, "unregister failed", t)
0
}
}
@@ -76,6 +130,25 @@ object F7PushRegistrar {
return code
}
private fun deleteRequest(endpoint: String, session: AuthSession): Int {
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.connectTimeout = 15000
conn.readTimeout = 15000
conn.requestMethod = "DELETE"
conn.setRequestProperty("Accept", "application/json")
conn.setRequestProperty("OCS-APIRequest", "true")
val basic = android.util.Base64.encodeToString(
"${session.username}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8),
android.util.Base64.NO_WRAP
)
conn.setRequestProperty("Authorization", "Basic $basic")
val code = conn.responseCode
drainQuietly(if (code >= 400) conn.errorStream else conn.inputStream)
conn.disconnect()
return code
}
private fun drainQuietly(stream: InputStream?) {
if (stream == null) return
try {
+6 -6
View File
@@ -13,13 +13,13 @@
| shell | b-dev-mobile | 0 | — | — | — | — | — | карта |
| files | b-mob-files | 1 | — | — | 4 | ? | нет | правки D4→D3→D2→D1 |
| mail | b-mob-mail | 1 | — | — | ~4 | ~2 | нет | правка P1 черновики |
| talk | b-mob-conf | 1 | 40.0 | предв. | ~31 | ? | нет | P1: меню сообщения Копировать/ссылка/переслать/Напоминание (59543a7,bbaaf7a); далее лично/файл-тред/перевод; Fd на устройстве |
| 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 | 0.0 | 0.0 | 42 | 21 | нет | diff выложен (`notes.diff.md`); натив пуст — модуль не создан, жду скелет от лида (пинг «готов к регистрации», 093) |
| notifications | b-mob-notify | 0 | — | — | | | нет | карта готова, diff |
| deck | b-mob-deck | 1 | 22.1 | предв. | 53 | ? | нет | diff выложен (`deck.diff.md`); Fd на устройстве; жду приоритизации лида, P1 — меню карточки/бейджи/меню колонки |
| calendar | b-mob-calendar | 1 | 51.6 | 67.0 | ~38 | ~7 | нет | diff итер.1 (`calendar.diff.md`); P1 разворот повторений; Appointments — вопрос n-a лиду |
| contacts | b-mob-contacts | 1 | 26.7 | предв. | ~40 | ? | нет | diff выложен (7 экранов); P1 — edit/delete (core-правка одобрена 072), далее группы/типы |
| 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 | 1 | 63.6 | 20.0 | 8 | 8 | нет | **P1 ЗАКРЫТ** (лента `a30627a`, unregister `cc7ad71`+`255c962`, retry `a6bda89`). **Fd идёт:** Roborazzi-эталон строки снят (`8072539`, w360dp); 2 Д-находки закрыты лидом+верифицированы (перенос текста кнопки, зазор subject/message — `bfa870c`); detekt designsystem был красный, лид починил. Осталось Ф P2/P3; Fd — веб-эталон 360px + сверка (лента `cloude` пуста, ждёт populated) |
| deck | b-mob-deck | 1 | 25.3 | предв. 2/8 | 47 | ~4 | нет | Ff P1 done (назначить-себя/бейджи/меню колонки/быстрые сроки, 6eeba1b…eff3f1c). Fd: Roborazzi 360dp поднят (58f11c2, native PNG), сверено 2 экрана (Д: бейджи иконки-vs-эмодзи, аватар исполнителя, колонка Участники); далее 6 экранов+меню. Крупный Ff (назначение юзера/Комментарии/Вложения/обзор/CRUD) — жду приоритеты лида |
| 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 | 37.4 | предв. | ~32 | ? | нет | P1 edit/delete в main (bc1a497+6b98c5a); P2 шаг1 типы TEL/EMAIL в main (a8741e9: парс+подписи+сохранение, 14 тестов); далее P2 шаг2 селектор типа + группы CATEGORIES; live/device далее |
Обозначения статуса: `карта``diff` (итерация 1) → `правки``повторный прогон`
`✅ 0` (Ff=Fd=100%) → `✅ 0 подтв.` (второй прогон держит 0).
+25 -17
View File
@@ -1,8 +1,13 @@
# Diff: Календарь (Calendar) — итерация 1 (2026-07-13)
Ff_app: **51.6%** · Fd_app: **67.0%** (среднее по 6 экранам, невзвешенно) · n-a: 0
_(без экрана Appointments, если владелец сочтёт его мобильно-неприменимым: Ff_app **62.0%** /
Fd_app **80.4%** по 5 экранам — вопрос лиду в конце.)_
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`,
@@ -21,17 +26,17 @@ mail/085) добавлю следующим проходом — этот про
---
## Экран 1. Календарь (сетка + шапка + виды) — Ff 90.0% · Fd 75.0%
Ф (10, open 1):
## Экран 1. Календарь (сетка + шапка + виды) — Ff 100% · Fd 75.0%
Ф (10, open 0):
- [Ф][done] предыдущий/следующий период · [Ф][done] «Сегодня»
- [Ф][done] переключатель вида: День/Неделя/Месяц/**Год/Список** (все 5, `CalendarViewMode`)
- [Ф][done] «Создать событие» · [Ф][done] открыть навигацию (бургер)
- [Ф][done] тап по дню → выбор дня · [Ф][done] тап по событию → открыть
- [Ф][done] «Показать незапланированные задачи» (`loadUnscheduledTasks`)
- [Ф][open] **разворот повторяющихся событий в сетке**`rememberEventsByDay`
`eventOccursOnDay` кладёт событие только на DTSTART (для timed) / DTSTART..DTEND (all-day),
**RRULE НЕ итерируется**: 2-я и дальше оккуренции в сетке/списке не видны (долг №2 mail/072,
подтверждён лидом). Это ядро P1.
- [Ф][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] пиксель-вид
@@ -85,8 +90,11 @@ mail/085) добавлю следующим проходом — этот про
[Д][done] секция импорта · [Д][open] отсутствуют секции General/Editing/Availability/CalDAV ·
[Д][open] порядок и группировка пунктов по теме.
## Экран 5. Appointments / Расписание встреч — Ff 0.0% · Fd 0.0%
Ф (10, open 10) — **раздел отсутствует в нативе целиком**:
## Экран 5. Appointments / Расписание встреч — `n-a` (вне метрики, mail/101)
Решение лида: организаторская фича (публикация слотов, внешние букинги) — десктоп/веб-воркфлоу,
на мобильном клиенте неприменима, тянет тяжёлый UI. Экран целиком `n-a`, из знаменателя исключён,
остаётся в финальном списке с обоснованием.
Ф (10, все `n-a`) — раздел отсутствует в нативе (осознанно):
- [Ф][open] список расписаний встреч · [Ф][open] «Создать расписание встреч»
- [Ф][open] поля: имя/описание/местоположение/длительность/инкремент
- [Ф][open] «Календарь бронирования» · [Ф][open] «Календари для проверки конфликтов»
@@ -94,8 +102,7 @@ mail/085) добавлю следующим проходом — этот про
- [Ф][open] «Создать комнату Talk» для встречи · [Ф][open] публичная страница бронирования
(внешний гость: выбор слота → Подтвердить)
Д (n/a пока — экран не реализован).
> ВОПРОС ЛИДУ: Appointments мобильно-применимы или помечаем `n-a` (организаторская фича, чаще
> десктоп)? От ответа зависит, входит ли экран в знаменатель (см. шапку — две метрики).
> Решено (mail/101): `n-a` — вне знаменателя.
## Экран 6. Окно события — Ff 83.3% · Fd 92.0%
Ф (30, open 5):
@@ -140,10 +147,11 @@ pill-поля 40dp, шапка с крестиком/галочкой, высо
Натив: Изменить `done` (открывает диалог с удалением) · Экспорт/Копировать-ссылку `open`.
## Предлагаемый порядок правок (жду приоритизацию лида)
- **P1 — Разворот повторений в сетке/списке** (Экран 1, долг №2): в `CalendarRepository`
реализовать генерацию оккуренций по RRULE в видимом диапазоне поверх CTag-кэша (по граблям из
ответа лида mail/072: развёртка ПОСЛЕ парсинга, экземпляры не кэшировать, ключ уникальности
`uid+recurrenceStart`). Заодно вскроется `RecurrenceRule.toRrule` для `BYDAY=1MO`.
- **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): вынести веб-параметры (номера недель/выходные/задачи-в-календаре/
+51 -36
View File
@@ -1,5 +1,15 @@
# Diff: Контакты (CardDAV) — итерация 1 (2026-07-13)
Ff_app: **26.7%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 3
Ff_app: **37.4%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 8
> Прогресс P1 (коммиты bc1a497 + 6b98c5a): правка/удаление РЕАЛИЗОВАНЫ (ядро + UI). Ядро:
> `CardDavClient.updateContact` (PUT If-Match, round-trip неизвестных полей vCard) / `deleteContact`
> (DELETE If-Match) + `VCardEditor`, 10 юнит-тестов зелёные. UI: Room href/etag/raw (миграция v4→5),
> кнопки «Править» (форма) и «Удалить» (с подтверждением) в карточке. Гейты зелёные
> (`:app:compileReleaseKotlin`, `:feature:contacts:detekt`+compile). Пункты «Править»/«Удалить» (Э3) и
> поля формы (Э4) отмечены `[done]` — реализованы в коде; live-проверка на `bmob-*` (mail/085) и
> дизайн-проход на устройстве — следующим шагом (Ff по коду, Fd предварительно). NB: P1-edit
> нормализует TYPE у EMAIL/TEL к INTERNET/CELL — сохранение/правка типов идёт в P2.
> Решение лида (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]`,
@@ -31,7 +41,7 @@ Fd (дизайн) — отдельным проходом на устройст
- [Ф][open] Команды: «Показать все команды» — нет.
- [Ф][open] «Параметры контактов» — нет (см. Экран 7).
## Экран 3: Карточка контакта (просмотр) — Ff 42.1% (11 open Ф / 19)
## Экран 3: Карточка контакта (просмотр) — Ff 52.6% (9 open Ф / 19; «Править»/«Удалить» done — P1)
Ряд действий (веб) ↔ натив (`ContactDetailContent`):
- [Ф][done] Написать письмо (конверт) · натив «Email» (ACTION_SENDTO mailto).
*расхождение: уходит во внешний почтовик, не в свой `MailComposeActivity` (план mail/072 п.3).*
@@ -50,30 +60,31 @@ Fd (дизайн) — отдельным проходом на устройст
- [Ф][open] «ID в объединении облачных хранилищ» (federated cloud ID) — нет.
- [Ф][open] «Подробное имя» (Фамилия/Имя/фонетика отдельными полями) — натив показывает только displayName.
Меню шапки:
- [Ф][open] «✏️ Править» — нет (Экран 4).
- [Ф][done] «✏️ Править» — реализовано (P1, 6b98c5a): кнопка в карточке → форма (Экран 4).
- [Ф][done] «Удалить» — реализовано (P1): кнопка с подтверждением, DELETE If-Match.
- [Ф][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) — нет.
## Экран 4: Правка/создание контакта (форма) — Ff 53.3% (7 open Ф / 15; правка реализована — P1)
Веб-форма правки ↔ натив (`EditContactDialog`правка + `AddContactDialog` — создание):
- [Ф][done] Имя · поле формы.
- [Ф][done] Телефон (значение) · поле формы (многострочное, несколько номеров).
- [Ф][done] Email (значение) · поле формы (многострочное, несколько адресов).
- [Ф][done] Сохранить · `updateContact` (round-trip vCard, PUT If-Match) / `createContact`.
- [Ф][done] Правка существующего (загрузка контакта в форму, PUT If-Match) — реализовано (P1, 6b98c5a).
- [Ф][done] Должность/организация — поля формы (organization + title).
- [Ф][done] Адрес — поле формы.
- [Ф][done] «Личные даты» (день рождения) — поле формы (текст; дата-пикер — доработка).
- [Ф][open] «Добавить фото» — нет.
- [Ф][open] Должность/организация — нет в форме.
- [Ф][open] Тип телефона (Домашний/Рабочий/CELL/…) — нет (селектор типа).
- [Ф][open] Тип email — нет.
- [Ф][open] Адрес — нет в форме.
- [Ф][open] «Личные даты» (день рождения, дата-пикер) — нет.
- [Ф][open] «О себе» — нет.
- [Ф][~] Тип телефона (Домашний/Рабочий/CELL/…) — типы ПАРСЯТСЯ и сохраняются при правке (P2 шаг1,
a8741e9); отдельного селектора типа в форме пока нет — P2 шаг2.
- [Ф][~] Тип email — аналогично (парсинг+сохранение есть, селектор — P2 шаг2).
- [Ф][open] «О себе» (NOTE) — нет в форме.
- [Ф][open] «Часовой пояс» — нет.
- [Ф][open] «Группы контакта» (добавить в группу) — нет.
- [Ф][open] «Группы контакта» (добавить в группу) — нет — P2.
- [Ф][open] Выбор адресной книги при создании — нет (пишет в `books.firstOrNull()`).
- [Д][—] Веб — полноэкранная форма; натив — компактный диалог. Расхождение композиции (Fd на устройстве).
@@ -84,19 +95,20 @@ Fd (дизайн) — отдельным проходом на устройст
- [Ф][open] Разворачивание/сворачивание узлов (`^N`).
- [Ф][open] Переход к карточке из узла.
## Экран 6: Команда (например «Тест») — Ff 0.0% (0 open Ф / 5; n-a 3)
Отсутствует в нативе:
- [Ф][open] Просмотр команды (название/владелец/участники).
- [Ф][open] «✏️ Править» команду.
- [Ф][open] «Копировать ссылку».
- [Ф][open] «Добавить» участника.
- [Ф][open] Список участников (роль «Владелец» и т.д.).
- [Ф][n-a] Создать «Папка» — зона `feature/files` (создаётся из Файлов).
## Экран 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`.
> n-a обоснование: три «Создать …» из карточки команды — кросс-модульные создающие потоки чужих
> модулей, достижимы из их приложений; в контактах мобильно-неприменимы. Команды (Circles/Teams)
> целиком — кандидат на согласование с лидом: нужен ли отдельный экран команд в мобильных Контактах.
> Обоснование: Circles = отдельное приложение (мобильно — не в скоупе Контактов v1); три «Создать …»
> — кросс-модульные создающие потоки чужих модулей. NB: «команды» ≠ «группы контактов»: группы
> (CATEGORIES→чипы) остаются в скоупе (P2).
## Экран 7: Параметры контактов (модальный шит) — Ff 0.0% (5 open Ф / 5)
Отсутствует в нативе:
@@ -108,7 +120,11 @@ Fd (дизайн) — отдельным проходом на устройст
---
## Сводка и приоритеты (прошу приоритизацию у лида)
Ff_app **26.7%** по 7 экранам (Э1 100 · Э2 18.2 · Э3 42.1 · Э4 26.7 · Э5/6/7 0). Fd — предварительно.
Ff_app **37.4%** по 6 экранам (Э1 100 · Э2 18.2 · Э3 52.6 · Э4 53.3 · Э5 0 · Э7 0; Э6 Команды = n-a, исключён). Fd — предварительно.
P1 (правка/удаление) закрыт в коде — Э3/Э4 подросли. **P2 шаг1 (типы TEL/EMAIL)** — в коде
(a8741e9): типы парсятся из raw, в карточке подписи по типу (Рабочий/Личный/Мобильный/…),
при правке типы сохраняются (fix P1-каветата), +4 теста. Дальше P2 шаг2: селектор типа в форме +
**группы CATEGORIES** (чипы-фильтр в списке — поднимет Э2). Шторка (Э2) и параметры (Э7) — P3/P5.
Крупнейшие функциональные дыры (кандидаты P1–P5), совпадают с моим планом mail/072:
1. **Правка/удаление контакта** (Экран 4 + ⋯ карточки): edit-форма, `updateContact` PUT If-Match с
round-trip неизвестных полей vCard, `deleteContact`, выбор адресной книги, поля должность/адрес/
@@ -122,12 +138,11 @@ Ff_app **26.7%** по 7 экранам (Э1 100 · Э2 18.2 · Э3 42.1 · Э4 2
5. **Параметры** (Экран 7): сортировка, импорт, управление адресными книгами.
Прочее: соц.сеть/cloud-ID/QR/скачать/копировать в карточке; оргструктура-граф; экран команд (см. n-a).
## Вопросы к лиду (b-dev-mobile)
- Порядок P1–P5? Мой дефолт (не жду ответа, начинаю с P1 edit/delete — целиком мой модуль +
твоё уже-данное добро на core-правку): 1→2→(4 кросс-модуль)→3→5.
- **Команды (Circles/Teams)** (Экран 6) — нужен ли отдельный экран команд в мобильных Контактах,
или считаем n-a целиком? Три «Создать …» я пометил n-a (чужие модули) — подтверди.
- **Оргструктура** (Экран 5) — в скоуп мобильных Контактов или n-a?
## Вопросы к лиду (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, ограждения)
+44 -16
View File
@@ -1,6 +1,18 @@
# Diff: Карточки (Deck) — итерация 1 (2026-07-13)
Ff_app: **22.1%** (53 open Ф / 76) · Fd_app: **предварительно** (веб-вёрстка сверена live через
iframe 390px; натив-дизайн-проход на устройстве — след. итерация) · n-a: 4
Ff_app: **25.3%** (47 open Ф / 76) · Fd_app: **предварительно, 2/8 экранов** (Roborazzi-пайплайн
поднят, mail/111) · n-a: 4
> **Fd-пайплайн (mail/111):** Roborazzi 360dp живой — `feature/deck/src/test/DeckScreenshotTest`
> рендерит нативные экраны в PNG (`src/test/screenshots/deck-boards.png`, `deck-board.png`,
> коммит 58f11c2). Веб-эталон снимаю iframe-360 на forbion (sample-доска «Welcome», без данных
> `cloude`). Сверено 2 экрана (список досок, доска-стек); Д-находки — ниже. Остальные 6 экранов
> + всплывающие меню (mail/087) — след. проходы.
> Прогресс P1: (6eeba1b) меню карточки «**Назначить мне / Снять**»; (9d726f4) **бейдж
> комментариев** «💬 N»; (73762d8/4fdadef) **меню колонки** «⋯» — «**Переименовать**» и
> «**Удалить список**» (`renameStack`/`deleteStack`) — все `[done]` ниже (Ff экрана 4:
> 52.0%→68.0%). **Compile + detekt модуля GREEN** (detekt-baseline модуля обновлён — v0.5.144-долг,
> как у calendar e7d0a5a; вписывается в свип лида mail/095).
Сверка веб (карта `deck.md`, апстрим `nextcloud/deck v1.16.4` + тема forbion, live-сверка
Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`DeckScreen`/
@@ -36,9 +48,12 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
## Экран 2: Все доски (список) — Ff 50.0% (1 open Ф / 2)
- [Ф][done] Открыть доску (тап строки) — есть.
- [Ф][open] Поиск/фильтр списка досок (`navFilter`) — нет.
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски.
- [Д][—] Строка-карточка доски (bullet цвета + название, border/radius) — сверено, близко 1:1
(натив `DeckBoardRow`); детальный Fd — на устройстве.
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски
(`сверено:` Roborazzi vs iframe-360: веб — таблица с колонкой аватаров справа, натив — только bullet+имя).
- [Д][open] **Заголовки-колонки** «Название доски / Участники» — в вебе есть строка-шапка таблицы,
в нативе её нет (список без шапки). Fd-минус композиции.
- [Д][done] Строка доски: bullet цвета + название, border/radius 8 — сверено (Roborazzi
`deck-boards.png` ↔ веб), совпадает.
## Экран 3: Обзор / «Ожидающие выполнения» — Ff 0.0% (2 open Ф / 2)
- [Ф][open] Дашборд карточек по срокам (Просроченные/Сегодня/Завтра/Ближайшие 7 дней/Позже/
@@ -46,7 +61,7 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
- [Ф][open] Открыть карточку из обзора — нет (нет обзора).
- [Д][open] Композиция: 6 секций-капшенов в столбец, карточки внутри — отсутствует.
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 52.0% (12 open Ф / 25)
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 72.0% (7 open Ф / 25)
**Шапка доски (`Controls`):**
- [Ф][done] «**Добавить список**» — натив «+ Колонка» (`createStack`).
- [Ф][open] «**Применить фильтр**» (по метке / назначенному / статусу) — фильтров нет.
@@ -56,21 +71,25 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
**Всплывающее меню колонки (⋯, `Stack`)** — в нативе меню колонки НЕТ:
- [Ф][open] «**Архивировать все карточки**» — нет.
- [Ф][open] «**Разархивировать все карточки**» — нет.
- [Ф][open] «**Удалить список**» — нет.
- [Ф][open] «**Изменить заголовок списка**» (инлайн-правка) — нет (переименования колонки нет).
- [Ф][done] «**Удалить список**» — реализовано (меню «⋯» → подтверждение `DeckConfirmDialog`).
- [Ф][done] «**Изменить заголовок списка / Переименовать**» — реализовано (меню «⋯» → диалог с
префиллом, `renameStack`).
- [Ф][done] «**Добавить карточку**» в колонку — есть (инлайн-поле «Имя карточки»).
**Карточка на доске (`CardItem` + бейджи):**
- [Ф][done] Открыть карточку (тап) — открывает лист-деталь.
- [Ф][done] Правка заголовка — есть (в листе-детали; веб правит инлайн — Д-нюанс).
- [Ф][done] Бейдж срока (`DueDate`) — есть.
- [Ф][open] Бейдж «**чек-пунктов**» (Todo items, напр. 0/9) — натив не показывает.
- [Ф][open] Бейдж «**комментариев**» (N, M непрочитанных) — не показывает.
- [Ф][done] Бейдж «**чек-пунктов**» «☑ x/total» — реализовано (`deckChecklistCounts`, парсинг
чекбоксов описания regex как веб).
- [Ф][done] Бейдж «**комментариев**» «💬 N» — реализовано (`commentsCount`); индикатор M
непрочитанных — Д-нюанс на след. проход.
- [Ф][done] Аватары назначенных — есть (отображение).
**Всплывающее меню карточки (⋯ / long-press, `CardMenuEntries`)** — в нативе overflow-меню НЕТ
(тап открывает лист; часть действий — кнопками листа):
- [Ф][done] «**Сведения о карточке**» — тап открывает лист-деталь.
- [Ф][done] «**Изменить заголовок**» — в листе.
- [Ф][open] «**Назначить мне / Снять назначение с меня**» — назначения из клиента нет (только чтение).
- [Ф][done] «**Назначить мне / Снять назначение с меня**» — реализовано (кнопка в листе-детали,
`toggleAssignSelf`; назначение ПРОИЗВОЛЬНОГО пользователя — отдельный пункт, экран 5).
- [Ф][done] «**Отметить выполненной / невыполненной**» — чекбокс done есть.
- [Ф][open] «**Переместить/копировать карточку**» — есть перенос между колонками ОДНОЙ доски
(чипы), но нет копирования и переноса на ДРУГУЮ доску.
@@ -80,14 +99,23 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
- [Ф][done] Перенос карточки между колонками (в вебе drag-n-drop) — натив реализует переносом
через чипы «Колонка» (Д-нюанс: без DnD-жеста).
## Экран 5: Лист-деталь / сайдбар карточки (Свойства/Вложения/Комментарии/Активность) — Ff 33.3% (12 open Ф / 18)
**Fd-находки (Roborazzi `deck-board.png` ↔ веб iframe-360):**
- [Д][open] **Бейджи карточки — иконки vs эмодзи**: веб рисует SVG-иконки (календарь/чек-лист/
комментарий/аватар), натив — эмодзи (🗓/☑/💬/👤). Расхождение типографики/иконок.
- [Д][open] **Исполнитель**: веб — кружок-аватар, натив — «👤 имя» текстом. Композиция отличается.
- [Д][done] Колонка-стек: шапка (заголовок+счётчик+«+»+«⋯»), карточки с метками/чекбоксом/
заголовком — сверено, композиция близка 1:1.
- [Д][done] Метки-чипы (цветной фон 0.22 + рамка) и чекбокс done — сверено, совпадают.
## Экран 5: Лист-деталь / сайдбар карточки (Свойства/Вложения/Комментарии/Активность) — Ff 38.9% (11 open Ф / 18)
**Свойства (Details):**
- [Ф][done] Метки: назначить/снять (toggle меток доски) — есть.
- [Ф][open] Метки: «**Создать новую метку**» — натив только тогглит существующие.
- [Ф][open] Назначение: «**Назначить пользователю…**» — нет (write).
- [Ф][open] Назначение: снять назначение — нет.
- [Ф][done] Срок: выбрать дату (DatePicker) — есть.
- [Ф][open] Срок: «**быстрые опции**» (Позже сегодня/Завтра/В выходные/На след. неделе) — нет.
- [Ф][done] Срок: «**быстрые опции**» — реализованы чипы Сегодня/Завтра/Выходные/Неделя
(`deckQuickDueIso`). *(«Позже сегодня» с часом — Д-нюанс, натив ставит дату; на след. проход.)*
- [Ф][done] Срок: «**Убрать срок**» — есть («Сбросить»).
- [Ф][done] Срок: «Отметить выполненной» из блока срока — есть (done).
- [Ф][done] Описание: редактировать — есть.
@@ -135,12 +163,12 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
| 1. Навигация + меню доски | 16.7% | 10/12 |
| 2. Все доски (список) | 50.0% | 1/2 |
| 3. Обзор (по срокам) | 0.0% | 2/2 |
| 4. Доска (Controls/Колонка/Карточка) | 52.0% | 12/25 |
| 5. Лист-деталь карточки | 33.3% | 12/18 |
| 4. Доска (Controls/Колонка/Карточка) | 72.0% | 7/25 |
| 5. Лист-деталь карточки | 38.9% | 11/18 |
| 6. Сайдбар доски | 0.0% | 10/10 |
| 7. Move/copy карточки | 25.0% | 3/4 |
| 8. Настройки Deck | 0.0% | 3/3 |
| **Ff_app (среднее)** | **22.1%** | **53/76** |
| **Ff_app (среднее)** | **25.3%** | **47/76** |
## Приоритеты (черновик, жду визы лида — mail/071 план 1→2→3)
- **P1 (в своём модуле, наибольший охват):** меню карточки (Назначить мне/Переместить-копировать/
+64 -46
View File
@@ -1,13 +1,22 @@
# Diff: Заметки (Notes) — итерация 1 (2026-07-13)
Ff_app: **0.0%** · Fd_app: **0.0%** (среднее по 6 экранам) · n-a: 0
Ff_app: **42.8%** · Fd_app: **0.0%** (среднее по 6 экранам; Fd ждёт девайс-сверки) · n-a: 0
> ⚠️ **Особый случай: нативного модуля `feature/notes` ЕЩЁ НЕТ.** Он не заведён в
> `settings.gradle`/`app` — скелет создаёт лид по пингу «готов к регистрации» (mail/071 п.2,
> подтверждено в 093). Поэтому **все** функции/элементы веба — `open` (в нативе ноль), а
> счётчики честно 0%. Diff = полный бэклог на реализацию. **Код правок начну сразу, как лид
> заведёт скелет** — пинг «готов к регистрации» отправлен в ответе 093. До скелета коммитить в
> `feature/notes/**` физически некуда.
> **Скелет заведён лидом** (`133d80d`). **Итерация 1a — слой данных** (`8a8f1cf`) +
> **1b — UI read-only ядра** (`fb120d5`). `[done]` = в коде + верифицировано (compile + 7/7
> юнит-тестов MockWebServer + detekt, изолированный worktree); **Fd=0% пока держится — экраны
> собраны, но девайс/визуал-сверки ещё не было**. Пишущее проверено ЮНИТАМИ, не на forbion (mail/073).
>
> **Готово в коде (1a данные + 1b UI):**
> - Данные: `NotesApiClient` (v1: list exclude=content+If-None-Match, get, create, saveContent
> `If-Match`→412 `NoteConflictException`, setFavorite/Category/rename, delete) · `NotesRepository`
> (offline-first: 304/сеть→кэш) · `NotesCache` (дисковый ETag) · `Note`.
> - UI: `NotesViewModel` (список избранное↑+поиск, открытие/создание/сохранение/удаление,
> разрешение конфликта явным выбором) · `NotesScreen` (список+поиск+пусто-состояние Welcome+
> диалог конфликта) · `NoteEditorScreen` (plain markdown + debounce-автосейв + read-only).
> - **Осталось для полного P1:** регистрация экрана в app-шелле (переключение веб-пункта меню
> «Заметки» на нативный — зона лида, app/; пинг отправлен). **P2:** rich-редактор/preview,
> категории-навигация+CRUD, autotitle, undo, settings, шаринг + попиксельная вёрстка (Fd).
Сверка веб (`nextcloud/notes v5.0.0` + тема forbion) ↔ натив (**отсутствует**). Карта-источник:
[`notes.md`](notes.md) (снята из кода + визуально сверена на forbion, Browser 1, read-only).
@@ -18,17 +27,17 @@ Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
---
## Экран 1. Список заметок (главный) — Ff 0% · Fd 0%
**Ф (10, open 10):**
- [Ф][open] кнопка «Новая заметка» (создать + открыть редактор) — P1
- [Ф][open] поле «Искать заметки» (фильтр по заголовку) + очистка — P2
- [Ф][open] строка-заметка, тап → открыть редактор — P1
## Экран 1. Список заметок (главный) — Ff 50% · Fd 0%
**Ф (10, open 5):**
- [Ф][done] создать заметку (`NotesApiClient.create` + кнопка `F7CreateButton` в `NotesScreen`)
- [Ф][done] поле «Искать заметки» (фильтр по заголовку, `visibleNotes`) — UI собран
- [Ф][done] загрузка заметки (`get`) + списка (`listNotes` offline-first) + строки `F7ListCard`
- [Ф][open] индикатор «расшарена» на строке — P3
- [Ф][open] меню строки «⋯» (см. Экран 2) — P1
- [Ф][open] меню строки «⋯» полное (сейчас на строке только избранное+тап; ⋯-поповер) — P2
- [Ф][open] группировка по таймслотам (сегодня/недавние/…) — P2
- [Ф][open] группировка по категориям (капшены) — P2
- [Ф][open] сортировка «избранное вверх» + по дате изменения — P2
- [Ф][open] пустое состояние Welcome (текст + «Новая заметка» + «создать образец») — P2
- [Ф][done] сортировка «избранное вверх» + по дате изменения (`visibleNotes`)
- [Ф][done] пустое состояние Welcome (`NotesEmptyState`: текст + «Новая заметка»)
- [Ф][open] «Найти во всех категориях» (сброс фильтра при пустом поиске в категории) — P3
- (состояние загрузки «Загрузка …» — Д)
@@ -37,42 +46,43 @@ Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
[Д][open] типографика (Raleway, тема) · [Д][open] иконки (звезда избранного, share-индикатор) ·
[Д][open] состояние загрузки/пустое
## Экран 2. Меню заметки «⋯» (всплывающее, mail/087) — Ff 0% · Fd 0%
## Экран 2. Меню заметки «⋯» (всплывающее, mail/087) — Ff 66.7% · Fd 0%
Полный состав из кода (`NoteItem` #actions), порядок сверен визуально:
**Ф (6, open 6):**
- [Ф][open] «Добавить/Удалить из избранного» (`PUT /favorite`) — P1
**Ф (6, open 2):**
- [Ф][done] «Добавить/Удалить из избранного» (`setFavorite`, юнит-тест) — данные готовы; UI P1-UI
- [Ф][open] «Поделиться» → сайдбар шаринга (NC sharing) — P3
- [Ф][open] «<Категория>» / «Изменить категорию» (мультиселект + создать новую, `PUT /category`) — P2
- [Ф][open] «Переименовать» (инлайн, `PUT /title`) — P2
- [Ф][open] «Удалить заметку» (`DELETE`) — P1
- [Ф][open] отмена удаления (`POST /notes/undo`) — P2
- [Ф][done] «<Категория>» / «Изменить категорию» (`setCategory`) — данные готовы; UI-мультиселект P2
- [Ф][done] «Переименовать» (`rename`) — данные готовы; UI-инлайн P2
- [Ф][done] «Удалить заметку» (`delete`, юнит-тест) — данные готовы; UI P1-UI
- [Ф][open] отмена удаления (`POST /notes/undo`) — не реализовано — P2
**Д (3, open 3):** [Д][open] состав/порядок пунктов меню · [Д][open] иконки пунктов ·
[Д][open] инлайн-поля (переименование/категория)
## Экран 3. Редактор заметки (rich / plain / preview) — Ff 0% · Fd 0%
**Ф (10, open 10):**
- [Ф][open] rich-редактор (@nextcloud/text) — P1
## Экран 3. Редактор заметки (rich / plain / preview) — Ff 40% · Fd 0%
**Ф (10, open 6):**
- [Ф][open] rich-редактор (@nextcloud/text) — P2
- [Ф][open] панель форматирования: жирный/курсив/цитата/моно/очистить/список/нумерованный/
заголовок/крупный заголовок/ссылка (всплывающая тулбар-панель, mail/087) — P2
- [Ф][open] plain-режим — P2
- [Ф][done] plain-режим (`NoteEditorScreen`, `F7OutlinedField` markdown) — UI собран
- [Ф][open] тумблер «Просмотр»/«Правка» (preview⇄edit) — P2
- [Ф][open] тумблер «Полный экран» — P3
- [Ф][open] автосохранение с `If-Match: "<etag>"` (`PUT /notes/{id}`) — P1
- [Ф][done] автосохранение с `If-Match: "<etag>"` (debounce → `saveContent`, юнит-тест 412)
- [Ф][open] автозаголовок из первой строки (`PUT /autotitle`) — P2
- [Ф][open] состояние read-only заметки (индикатор, блок правки) — P2
- [Ф][open] «Сохранение не удалось. Нажмите, чтобы повторить» (ручной ретрай) — P2
- [Ф][open] плейсхолдеры «Write …» / «Empty note» — P3
- [Ф][done] состояние read-only заметки (блок правки + подпись поля)
- [Ф][open] «Сохранение не удалось. Нажмите, чтобы повторить» (ошибка в state, кнопки ретрая нет) — P2
- [Ф][done] плейсхолдер поля (подпись `F7OutlinedField`)
**Д (5, open 5):** [Д][open] вёрстка редактора · [Д][open] перенос тулбара форматирования на
мобильном (`.text-menubar__entries` flex-wrap) · [Д][open] типографика тела · [Д][open] меню
действий редактора (preview/fullscreen) · [Д][open] индикатор read-only
## Экран 4. Диалог конфликта версий — Ff 0% · Fd 0%
**Ф (3, open 3):**
- [Ф][open] детект конфликта (сервер вернул конфликт на `If-Match`) — P1
- [Ф][open] «Использовать версию с сервера» — P1
- [Ф][open] «Использовать текущую версию» — P1
## Экран 4. Диалог конфликта версий — Ff 100% · Fd 0%
**Ф (3, open 0):**
- [Ф][done] детект конфликта (412 на `If-Match``NoteConflictException` + перечитка серверной
версии, юнит-тест)
- [Ф][done] «Использовать версию с сервера» (`F7AlertDialog` dismiss → `resolveConflictUseServer`)
- [Ф][done] «Использовать текущую версию» (`F7AlertDialog` confirm → `resolveConflictKeepMine`)
- (⚠ обязателен — НЕ молчаливая перезапись, согласовано mail/071 п.2)
**Д (1, open 1):** [Д][open] вёрстка диалога конфликта
@@ -108,15 +118,17 @@ Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
## Сводка
| Экран | Ф всего | Ф open | Ff% | Д всего | Д open | Fd% |
|---|---|---|---|---|---|---|
| 1. Список | 10 | 10 | 0% | 6 | 6 | 0% |
| 2. Меню «⋯» | 6 | 6 | 0% | 3 | 3 | 0% |
| 3. Редактор | 10 | 10 | 0% | 5 | 5 | 0% |
| 4. Конфликт | 3 | 3 | 0% | 1 | 1 | 0% |
| 1. Список | 10 | 5 | 50.0% | 6 | 6 | 0% |
| 2. Меню «⋯» | 6 | 2 | 66.7% | 3 | 3 | 0% |
| 3. Редактор | 10 | 6 | 40.0% | 5 | 5 | 0% |
| 4. Конфликт | 3 | 0 | 100% | 1 | 1 | 0% |
| 5. Категории | 8 | 8 | 0% | 4 | 4 | 0% |
| 6. Настройки | 5 | 5 | 0% | 2 | 2 | 0% |
| **Итого** | **42** | **42** | **0%** | **21** | **21** | **0%** |
| **Итого** | **42** | **26** | **38.1%** | **21** | **21** | **0%** |
**Ff_app = 0.0% · Fd_app = 0.0%** (натив пуст — модуль не создан).
**Ff_app = 42.8%** (среднее по 6 экранам: 50+66.7+40+100+0+0) · **Fd_app = 0.0%** (экраны
собраны, но девайс/визуал-сверки ещё не было). Абсолютно: 16 из 42 Ф закрыты в коде+UI, 26 open.
Fd — весь бэклог до попиксельной сверки на устройстве.
## Предлагаемый порядок реализации (план 1→2→3, mail/071)
- **P1 — read-only ядро:** список заметок (открыть/просмотр), rich-редактор, автосейв `If-Match`,
@@ -127,8 +139,14 @@ Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
режим, расширение файла/папка, попиксельная вёрстка по `_mobile-notes.css`. (= итерация 3)
## Блокеры
1. **Скелет модуля** заводит лид (mail/071/093); до него код не пишу. Пинг «готов к
регистрации» — в ответе 093.
2. **NC-апп `notes` на glb** — установлен ли (для интеграционной проверки записи); лид спросил
в PPS 069. До ответа write-путь — на unit-тестах (`core:network`), не на forbion (read-only, mail/073).
3. Повторный (подтверждающий) прогон для гейта — после реализации P1, на устройстве/`bmob-*`.
1. ~~Скелет модуля~~**снят**: заведён лидом (`133d80d`, mail/096). Слой данных 1a — в коде (`8a8f1cf`).
2. **NC-апп `notes` на glb** — установлен ли (для интеграционной проверки записи на живом
сервере); лид спросил в PPS 069. До ответа write-путь проверяется юнит-тестами (MockWebServer),
не на forbion (read-only, mail/073). НЕ блокирует разработку — только живую верификацию.
3. Повторный (подтверждающий) прогон для гейта — после реализации P1-UI, на устройстве/`bmob-*`.
## Дальше (следующий заход, автономно)
P1-UI: `NotesScreen` (список+поиск+пусто-состояние) → `NoteEditorScreen` (rich/plain/preview +
автосейв) → диалог `ConflictSolution` → регистрация экрана у лида (переключение веб-пункта меню
на нативный — зона app/, делает лид по готовности). Затем P2 (категории-CRUD/autotitle/undo/
settings), P3 (шаринг/добивка вёрстки).
+115
View File
@@ -0,0 +1,115 @@
# Diff: Уведомления (Notifications) — итерация 1 (2026-07-13)
Ff_app: **63.6%** (done 14 / open 8, n-a 1) · Fd_app: **20.0%** (Д done 2 / open 8; Roborazzi-эталон снят, веб-сверка ленты pending) · автор `b-mob-notify`
> **Итерация 1b (2026-07-13):** P1-лента закрыта лидом (`a30627a`) + P1-push unregister закрыт
> (клиент `cc7ad71` + вызов в logout `255c962`). Ff 45.5% → 63.6%.
> Сверка веб (`nextcloud/notifications 5.0.0-dev.0` + тема forbion, mail/078) ↔ **натив
> (реализован в app-shell)**. Лента УЖЕ работает: `NotificationsSheet` (`app/AppSheets.kt`) +
> `F7NotificationRow` (`core/designsystem`) + `NotificationsRepository` (`core/network`, API v2).
> `[done]` = есть в коде натива; `Fd=0%` — вёрстка на устройстве/визуально ещё не сверялась.
> Карта-источник: [`notifications.md`](notifications.md) (из кода + живая сверка forbion,
> Browser 1/`cloude`, read-only; лента `cloude` = 3 уведомления `firstrunwizard`).
>
> **NB зона:** лента в `app/AppSheets.kt` + `core/*` (зона лида) — P1-функционал он закрыл сам
> (mail/113). Моё: core/push (retry+unregister — done) и **Fd (Roborazzi-строка уведомления в
> `core/designsystem/src/test` + веб-эталон 360 CSS-px)** — по разрешению лида (mail/112б).
> `feature/notifications` — после приёмочного гейта (mail/112б).
Метки: `[Ф|Д]` (функционал/дизайн) · `[open|done|n-a]`.
---
## Экран 1. Триггер (колокольчик) — Ff n-a (зона лида)
Колокольчик-вход живёт в шелле (нижняя шторка, слот `#mobile-header-notifications`) — **зона
`b-dev-mobile`** (shell.md), не считаю в свой Ff.
**Ф (1, n-a 1):**
- [Ф][n-a] иконка-триггер + раскрытие ленты — шелл (лид). Индикатор непрочитанных (точка
`showDot` / warning `hasThrottledPushNotifications`) — если понадобится, отдельным пингом лиду.
## Экран 2. Лента (дропдаун/шит) — Ff 71.4% · Fd 0%
**Ф (8, done 5, open 2, n-a 1):**
- [Ф][done] загрузка списка (`NotificationsRepository.load`, OCS API v2, `limit=50`)
- [Ф][done] пустое состояние «Нет уведомлений» (иконка `nof-not-icon.svg` + текст) — `items.isEmpty()`
- [Ф][done] состояние загрузки (спиннер `CircularProgressIndicator`)
- [Ф][done] состояние ошибки (текст `error`)
- [Ф][done] **«Скрыть все уведомления»** (`dismissAll()``DELETE …/api/v2/notifications`) —
лид `a30627a` (заголовок ленты + кнопка + оптимистичное скрытие)
- [Ф][open] баннер fairUsePolicy при throttled push («Push notifications might be unreliable») — P3
- [Ф][open] авто-обновление ленты: сейчас только push-триггер (`refreshRequest`), ETag/поллинга
(`lastETag`/notify_push) НЕТ — P2
- [Ф][n-a] пустой вариант «Запрос разрешений браузера» (`webNotificationsGranted===null`) — n-a:
веб-концепт web-push; натив на FCM, разрешение POST_NOTIFICATIONS запрашивается системно
**Д (5, open 5):** [Д][open] вёрстка ленты по теме/токенам · [Д][open] высота/скролл шита ·
[Д][open] пустое состояние (вид иконки/текста) · [Д][open] состояние загрузки/ошибки ·
[Д][open] анимация inbox-zero (галочка+конфетти при переходе >0→0) — P3
## Экран 3. Элемент уведомления (`F7NotificationRow``NotificationItem`) — Ff 58.3% · Fd 0%
**Ф (12, done 7, open 5):**
- [Ф][done] относительное время (`formatNotificationRelativeTime`) — совпадает с forbion («N минут назад»)
- [Ф][done] кнопка «Скрыть» (× → `dismissNotification``DELETE …/notifications/{id}`)
- [Ф][done] иконка app (`resolveNotificationIcon` + `AsyncImage` с auth-хедером)
- [Ф][done] subject (плоский текст)
- [Ф][done] message (плоский текст, если есть)
- [Ф][done] переход по тапу (`onNotificationClick``openAppLink(notification.link)`)
- [Ф][done] **action-кнопки уведомления** (`actions[]``F7NotificationActionUi`, FlowRow) —
лид `a30627a` (primary=заливка, прочие=контур); закрывает Talk-приглашения и т.п.
- [Ф][open] `subjectRich` (`NcRichText` + User/File-параметры) — только плоский subject — P2
- [Ф][open] `messageRich` (rich + autolink) — только плоский message — P2
- [Ф][open] `externalLink` (вариант «↗», target=_blank + primary-кнопка «Contact…») — не отличается
от обычной ссылки — P3
- [Ф][open] сворачивание длинного message (`.collapsed` + разворот по тапу) — нет — P3
- [Ф][open] подсветка нового `notification--new` — нет — P3
**Д (5, done 2, open 3):** нативный Roborazzi-эталон снят (`core/designsystem/src/test`
`notification-row-states.png`, w360dp, перезаписан `8072539` после фикса лида).
> **Веб-эталон 360 CSS-px — техническое ограничение:** лента уведомлений НЕ рендерится в
> same-origin iframe (приложение `notifications` координирует вкладки — `setCurrentTabAsActive`,
> фетчит только «активная» вкладка; iframe как вторая вкладка остаётся пустым, хотя OCS API
> отдаёт 3 уведомления `firstrunwizard`). Populated-строки живьём для попиксельной сверки пока
> недоступны через iframe. Обходы на изучении; часть Д закрыта по Roborazzi-эталону + карте.
- [Д][done] **перенос текста primary-кнопки посреди слова** — было «Присоединитьс/я» на w360dp;
лид `bfa870c` (подпись действия 1.5× вместо 2×, maxLines=2 по словам, паддинги 16/9).
Верифицировано перезаписью Roborazzi: «Присоединиться» в одну строку. Находка Roborazzi.
- [Д][done] зазор subject↔message (сливались на 360dp) — лид `bfa870c` (6→10dp), подтверждено на PNG.
- [Д][open] компоновка строки (время+× сверху, иконка+subject/message) — сверить с веб-эталоном.
- [Д][open] вёрстка по теме (карточный стиль `_mobile-common.css` + токены) — vs веб-эталон.
- [Д][open] типографика: натив рендерит удвоенным кеглем (`.doubled()`) — сверить с мобильной темой.
## Экран 4. Кнопка действия (`F7NotificationActionUi``ActionButton`) — Ff 66.7% · Fd 0%
Реализовано лидом (`a30627a`): парсинг `actions[]` (`parseActions`, type→uppercase, default GET,
primary), рендер `F7NotificationActionUi`, выполнение `executeAction`.
**Ф (3, done 2, open 1):**
- [Ф][done] primary/контур по флагу `primary` (`F7NotificationActionUi.primary`) — рендер FlowRow.
(нюанс упстрима: там `isWebLink||primary`→primary; в нативе только флаг `primary` — визуально
сойдётся, разберём в Fd, не Ф-дыра)
- [Ф][done] выполнение действия (`executeAction`: POST/PUT/DELETE, иначе GET, по абсолютному
`action.link` + `OCS-APIRequest`) + оптимистичное скрытие уведомления после
- [Ф][open] **WEB-действие как прямая ссылка/открытие в браузере**`executeAction` для типа
`WEB` уходит в `else→GET` (упстрим: WEB = открыть `link` в браузере, а не HTTP-GET). Пограничный
случай, но семантика иная — P3. (сообщу лиду, если встретится WEB-action живьём)
---
## Вне карты-экрана: core/push (моя зона, отдельный бэклог)
Не входит в Ff ленты (нет веб-экрана-аналога), но моя зона и уже зафиксировано в представлении
(mail/070). Держу как отдельный трек:
- [Ф][done] **unregister при logout** — ЗАКРЫТО. Контракт f7push (mail/110). Клиент
`F7PushRegistrar.unregisterBlocking` (`cc7ad71`, compile+detekt green) + вызов в
`MainViewModel.logout()` best-effort на IO рядом с ревокацией (лид `255c962`, mail/112а). P1-push.
- [Ф][done] retry/бэкофф в `F7PushRegistrar.registerBlocking` (3 попытки, бэкофф 1с/3с, повтор
на 0/5xx/408/429, стоп на 2xx/4xx) — коммит `a6bda89`, compile+detekt green (P2-push)
- [Ф][n-a] серверные хвосты FCM (чистка 403 SenderId mismatch, старые `f7cloud-apk`) — ячейка A,
через integrator-b (mail/064) — вне натива
## Сводка
- **Ff = 63.6%** (done 14 / open 8; n-a 3 не в счёте: web-permission-empty, триггер-шелл, серверный FCM).
- **P1 — ЗАКРЫТ:** «Скрыть все» + action-кнопки + парсинг `actions` (лид `a30627a`);
unregister-push (клиент `cc7ad71` + вызов `255c962`); retry регистрации (`a6bda89`).
- **P2 (осталось):** rich subject/message, авто-обновление ETag.
- **P3 (осталось):** fairUsePolicy-баннер, external-«↗», WEB-action как ссылка, сворачивание
message, `notification--new`, inbox-zero.
- **Fd=0% → в работе автономно:** Roborazzi-скрин `F7NotificationRow` в `core/designsystem/src/test`
(w360dp) + веб-эталон 360 CSS-px (пустое состояние, без данных `cloude`) — по mail/111/112б.
+35
View File
@@ -0,0 +1,35 @@
# Эталонные веб-скриншоты forbion (для Fd-сверки)
Здесь лежат **эталонные скриншоты мобильной веб-версии forbion**с ними Roborazzi-рендеры
нативных экранов сравниваются попиксельно (метрика Fd, программа 1:1). Одна папка на приложение:
`docs/app-map/reference/<app>/<screen>.png`.
## Ширина — 360dp (Figma-фрейм)
Рендер и эталон — **360dp** (не 390!). В screenshot-тестах Roborazzi:
`@Config(sdk=[34], qualifiers="w360dp-h800dp-xxhdpi")`. Эталонный веб-скрин тоже снимать на
ширине **360 CSS-px**.
## Как снять мобильную вёрстку (важно!)
`resize_window` НЕ ужимает вьюпорт общего Chrome (отдаёт десктоп 1480px). Снимаем через
**same-origin iframe 360px** внутри страницы forbion ([[f7-mobile-web-iframe-technique]]):
инжектируешь `<iframe width=360 src="/index.php/apps/<app>/…">` в открытую страницу forbion
(Browser 1, сессия `cloude`), ждёшь загрузки, скриншотишь регион iframe. Мобильная тема
включается по ширине iframe (у него свой вьюпорт 360px).
## ⚠️ Приватность — НЕ светить реальные данные
`cloude` — реальный рабочий аккаунт: на экранах видны настоящие файлы/письма/договоры/контакты.
**В эталон НЕ коммитим реальные данные.** Снимаем:
- пустые состояния, ИЛИ
- свои тест-объекты `bmob-*` (mail/085), ИЛИ
- кадрируем так, чтобы в кадре был только UI-каркас (шапки/панели/кнопки/меню), без реального
контента; персональные имена/суммы/адреса замазать/обрезать.
Скрин с чужими реальными данными в git — недопустим.
## Что снимать (по карте `<app>.md`)
Каждый экран из карты + всплывающие меню (mail/087): список/деталь/форма/модалки/пустые
состояния/контекстные «⋯». Имя файла = слаг экрана (`list.png`, `editor.png`, `menu-message.png`).
## Кто заводит
Владелец приложения (`b-mob-<x>`) снимает эталоны СВОИХ экранов и коммитит сюда; лид — оболочку
(`shell/`). Затем — Roborazzi-тест нативного экрана → PNG → визуальное сравнение с этим эталоном,
расхождения в `<app>.diff.md` (тип `Д`).
+9 -7
View File
@@ -1,9 +1,11 @@
# Diff: Конференции (Talk) — итерация 1 (2026-07-13)
Ff_app: **40.0%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
Ff_app: **41.3%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
> Прогресс P1 (коммиты 59543a7, bbaaf7a): реализованы меню сообщения «Копировать», «Копировать
> ссылку», «Переслать в «Личные заметки»», «Напоминание» (пресеты) — `[done]` ниже
> (Ff экрана «Беседа» 38.1%→54.5%).
> Прогресс P1 (59543a7, bbaaf7a, a60fd78, 01198a4): меню сообщения «Копировать», «Копировать
> ссылку», «Переслать в «Личные заметки»», «Переслать…» (в любую комнату), «Напоминание»
> (пресеты), «Ответить лично» — `[done]` ниже (Ff экрана «Беседа» 38.1%→63.6%). Остаток P1:
> «Перейти к файлу/треду» (кросс-модуль files + тредов в нативе нет), «Перевод» (нужны провайдеры
> перевода на сервере — под вопросом/возможно n-a) — эскалирую лиду.
Сверка веб (карта `talk.md`, апстрим `spreed v22.0.12` + тема) ↔ натив (`feature/talk` Compose на
OCS /api/v4; звонки — `vendor/talk-android` v23). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
@@ -42,7 +44,7 @@ Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве
- Мастер создания: [Ф][done] имя/тип/аватар; [Ф][open] описание, защита паролем, «Разрешить
гостям по ссылке» — в нативе минимальный (имя+тип). *(учтено в open выше как часть «создать».)*
## Экран 3: Беседа + композер + меню сообщения — Ff 54.5% (10 open Ф / 22)
## Экран 3: Беседа + композер + меню сообщения — Ff 63.6% (8 open Ф / 22)
Композер:
- [Ф][done] Отправить сообщение · «Отправить».
- [Ф][done] Эмодзи · «Смайлики».
@@ -57,10 +59,10 @@ Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве
- [Ф][done] Изменить · «Изменить».
- [Ф][done] Удалить · «Удалить».
- [Ф][done] Реакция · набор эмодзи (реакции есть).
- [Ф][open] **Ответить лично** (Reply privately) — нет.
- [Ф][done] **Ответить лично** (Reply privately) — реализовано (a60fd78): открывает/создаёт 1:1 с автором.
- [Ф][done] **Переслать в «Личные заметки»** — реализовано (59543a7); пересылка в произвольную
комнату (выбор комнаты) — ещё open, вынес отдельным пунктом ниже.
- [Ф][open] Переслать в произвольную комнату (выбор из списка) — нет (пока только note-to-self).
- [Ф][done] Переслать в произвольную комнату (выбор из списка) — реализовано (01198a4, ForwardTargetSheet).
- [Ф][done] **Копировать сообщение** (Copy message → буфер) — реализовано (59543a7).
- [Ф][done] **Копировать ссылку** (Copy message link → буфер) — реализовано (59543a7).
- [Ф][done] **Напоминание** (Set reminder — сегодня/завтра/выходные/неделя) — реализовано (bbaaf7a);
+161 -81
View File
@@ -12,88 +12,124 @@
<ID>ArgumentListWrapping:CalendarComponents.kt$(12.dp)</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$(cal.accountName, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$(event.location, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted, maxLines = 1)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( "#0082C9", "#7CBC3D", "#E9322D", "#F1DB50", "#795AAB", "#E97D30", "#00C7B4", )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( attendee.displayName.ifBlank { attendee.email }, color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, hh, mm -&gt; onPick("%02d:%02d".format(hh, mm)) }, h, mi, true, )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, y, m, d -&gt; onPick(LocalDate.of(y, m + 1, d)) }, current.year, current.monthValue - 1, current.dayOfMonth, )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, y, m, d -&gt; rule = rule.copy(untilDate = LocalDate.of(y, m + 1, d)) }, until.year, until.monthValue - 1, until.dayOfMonth, )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( draft.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))), color = F7Colors.TextSecondary, fontSize = 13.sp, )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( if (draft.attachments.isEmpty()) "Нет ни одного вложения" else "Вложений: ${draft.attachments.size}", color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f), )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( sharee.displayName + if (sharee.writable) " (редактирование)" else " (чтение)", color = F7Colors.TextPrimary, fontSize = 14.sp, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( when (rule.endType) { RecurrenceRule.EndType.NEVER -&gt; "Никогда" RecurrenceRule.EndType.UNTIL -&gt; "До даты" RecurrenceRule.EndType.COUNT -&gt; "После N повторений" }, color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f), )</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("$count событ.", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("/${path}", color = F7Colors.TextSecondary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("ru")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Весь день")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Комната Talk")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Выберите беседу", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Выберите файл", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("До даты")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Занятость участников", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Или создать новую беседу", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Корзина пуста", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Настройки календаря", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Нет доступных бесед", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Нет задач без срока", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Никогда")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Отмена", color = F7Colors.TextSecondary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Папка пуста", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Повторять событие", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("После N повторений")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Удалить календарь и все его события?", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$('/')</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$('/', "")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(10.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(12.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(16.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(8.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(calendarHref = cal.href)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(classification = cl)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(recurrence = preset, customRrule = "")) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(status = st)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(18.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(1f)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(2.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(24.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(28.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(3.dp, F7Colors.TextPrimary, CircleShape)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(32.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(CircleShape)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(F7Colors.Primary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(F7Colors.PrimaryLight)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Add, contentDescription = "Добавить вложение", tint = EVENT_PLACEHOLDER, modifier = Modifier.size(18.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Close, contentDescription = "Убрать доступ", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Close, contentDescription = "Удалить напоминание", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Close, contentDescription = "Удалить", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.InsertDriveFile, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.KeyboardArrowDown, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Link, contentDescription = null, tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Notifications, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().clickable { onDraftChange(draft.copy(mode = mode)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(addTalkRoom = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(allDay = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(calendarHref = cal.href)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(categories = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.allDay, onCheckedChange = { onDraftChange(draft.copy(allDay = it)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(classification = cl)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(description = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(addTalkRoom = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(allDay = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(calendarHref = cal.href))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(categories = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(classification = cl))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(description = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.size(24.dp).clip(CircleShape).background(F7Colors.Primary), contentAlignment = Alignment.Center)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.size(32.dp).clip(CircleShape).background(F7Colors.PrimaryLight), contentAlignment = Alignment.Center)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(2.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(allDay = !draft.allDay)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(att.fileName.ifBlank { att.url.substringAfterLast('/') }, color = F7Colors.TextPrimary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(calendarName, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(allDay = !draft.allDay))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(endDate = picked.coerceAtLeast(draft.date).takeIf { it != draft.date }))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(endTime = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(mode = mode))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(name = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset, customRrule = ""))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(reminderMinutes = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = RecurrencePreset.NONE, customRrule = rule.toRrule()))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(reminders = draft.reminders - min))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(startTime = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(status = st))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(subscriptionUrl = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(title = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.date)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.endTime)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.startTime)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(endDate = picked.coerceAtLeast(draft.date).takeIf { it != draft.date })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(endTime = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(entry.name, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(freqLabel(rule.freq, rule.interval), color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(horizontal = 16.dp, vertical = 14.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(icon = Icons.Default.Schedule, modifier = Modifier.weight(1f), onClick = { pickTime(draft.endTime) { onDraftChange(draft.copy(endTime = it)) } })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(icon = Icons.Default.Schedule, modifier = Modifier.weight(1f), onClick = { pickTime(draft.startTime) { onDraftChange(draft.copy(startTime = it)) } })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (month == YearMonth.from(selectedDay)) F7Colors.PrimaryLight else F7Colors.Surface)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (selected) Modifier.border(3.dp, F7Colors.TextPrimary, CircleShape) else Modifier)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru"))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(max = 520.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(label, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(min)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(mode = mode)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(name = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = onDelete, enabled = !state.deleting)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset, customRrule = "")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = { confirmDelete = false })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = { onDraftChange(draft.copy(reminders = draft.reminders - min)) }, modifier = Modifier.size(24.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = { onNavigate(path.substringBeforeLast('/', "")) }, modifier = Modifier.size(28.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(path.substringBeforeLast('/', ""))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = RecurrencePreset.NONE, customRrule = rule.toRrule())</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(rememberScrollState())</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminderMinutes = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.classification == cl, onClick = { onDraftChange(draft.copy(classification = cl)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.recurrence == preset, onClick = { onDraftChange(draft.copy(recurrence = preset)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(start = 8.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminderLabelFor(min), color = F7Colors.TextPrimary, fontSize = 13.sp, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminders = draft.reminders - min)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(room.displayName, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(rule.freq, rule.interval)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(startTime = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(status = st)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(subscriptionUrl = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(title = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = { Text("До даты") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.UNTIL, untilDate = rule.untilDate ?: LocalDate.now().plusMonths(1)); endMenu = false })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = { Text("Никогда") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.NEVER); endMenu = false })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = { Text("После N повторений") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.COUNT); endMenu = false })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(top = 12.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.attendeeQuery, onValueChange = onSearchAttendees, label = "Поиск участника")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.endTime, onValueChange = { onDraftChange(draft.copy(endTime = it)) }, label = "Окончание")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.name, onValueChange = { onDraftChange(draft.copy(name = it)) }, label = "Название")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.reminderMinutes.toString(), onValueChange = { v -&gt; v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.title, onValueChange = { onDraftChange(draft.copy(title = it)) }, label = "Название")</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d", Locale.ENGLISH)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d, yyyy", Locale.ENGLISH)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = name, onValueChange = { name = it }, placeholder = "Название календаря", modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(vertical = 12.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onDraftChange(draft.copy(allDay = !draft.allDay)) })</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("d MMM yyyy", RU)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("d MMM", RU)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("dd.MM.yyyy HH:mm")</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("d MMM yyyy", RU))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("d MMM", RU))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(ZoneId.systemDefault())</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(epoch)</ID>
@@ -102,35 +138,62 @@
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 1, 1)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 12, 31)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(start)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl })</ID>
<ID>ArgumentListWrapping:RecurrenceRule.kt$RecurrenceRule.Companion$( "MO" to "Пн", "TU" to "Вт", "WE" to "Ср", "TH" to "Чт", "FR" to "Пт", "SA" to "Сб", "SU" to "Вс", )</ID>
<ID>ArgumentListWrapping:RecurrenceRule.kt$RecurrenceRule.Companion$("dd.MM.yyyy")</ID>
<ID>ArgumentListWrapping:RecurrenceRule.kt$RecurrenceRule.Companion$(DateTimeFormatter.ofPattern("dd.MM.yyyy"))</ID>
<ID>CyclomaticComplexMethod:CalendarExtendedUi.kt$@Composable fun CalendarFullEventEditorDialog( state: CalendarUiState, onDismiss: () -&gt; Unit, onDraftChange: (CalendarEventDraft) -&gt; Unit, onSave: () -&gt; Unit, onDelete: () -&gt; Unit, onSearchAttendees: (String) -&gt; Unit, onAddAttendee: (CalendarAttendeeSuggestion) -&gt; Unit, onRemoveAttendee: (String) -&gt; Unit, onSearchLocations: (String) -&gt; Unit, onApplyLocation: (CalendarLocationSuggestion) -&gt; Unit, onOpenTalkPicker: () -&gt; Unit = {}, onPickDeviceAttachment: () -&gt; Unit = {}, onPickFilesAttachment: () -&gt; Unit = {}, onRemoveAttachment: (String) -&gt; Unit = {}, onOpenFreeBusy: () -&gt; Unit = {}, )</ID>
<ID>CyclomaticComplexMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, onFocusEventConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>CyclomaticComplexMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
<ID>ForEachOnRange:CalendarComponents.kt$0..23</ID>
<ID>ForEachOnRange:CalendarComponents.kt$0..24</ID>
<ID>ImportOrdering:CalendarComponents.kt$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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.material3.AlertDialog import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.offset import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import java.time.LocalTime 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
<ID>ImportOrdering:CalendarRepository.kt$import okhttp3.OkHttpClient import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.CalendarAlarmData import ru.forbion.f7cloud.core.network.CalendarAttendeeData import ru.forbion.f7cloud.core.network.CalendarEventData import ru.forbion.f7cloud.core.network.CalendarIcs import ru.forbion.f7cloud.core.network.CalDavClient import ru.forbion.f7cloud.core.network.DavCalendar import ru.forbion.f7cloud.core.network.DavEvent import ru.forbion.f7cloud.core.network.DavTask import ru.forbion.f7cloud.core.network.DavTrashEvent import ru.forbion.f7cloud.core.network.NetworkFactory import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId import java.time.ZoneOffset</ID>
<ID>ImportOrdering:CalendarComponents.kt$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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.offset import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import java.time.LocalTime 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
<ID>ImportOrdering:CalendarExtendedUi.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border 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.Row import androidx.compose.foundation.layout.RowScope 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.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.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.Notes import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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 import androidx.compose.ui.window.DialogProperties 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.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.LocalDate import java.time.YearMonth import java.time.format.DateTimeFormatter import java.util.Locale</ID>
<ID>ImportOrdering:CalendarRepository.kt$import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.CalendarAlarmData import ru.forbion.f7cloud.core.network.CalendarAttachmentData import ru.forbion.f7cloud.core.network.CalendarAttendeeData import ru.forbion.f7cloud.core.network.CalendarEventData import ru.forbion.f7cloud.core.network.CalendarIcs import ru.forbion.f7cloud.core.network.CalDavClient import ru.forbion.f7cloud.core.network.DavCalendar import ru.forbion.f7cloud.core.network.DavClient import ru.forbion.f7cloud.core.network.DavEvent import ru.forbion.f7cloud.core.network.DavTask import ru.forbion.f7cloud.core.network.DavTrashEvent import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.network.davFileUrl import ru.forbion.f7cloud.core.network.davFolderUrl import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId import java.time.ZoneOffset</ID>
<ID>ImportOrdering:CalendarScreen.kt$import android.Manifest import android.content.pm.PackageManager import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.delay import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import java.time.format.DateTimeFormatter import java.util.Locale</ID>
<ID>Indentation:CalendarScreen.kt$ </ID>
<ID>LargeClass:CalendarViewModel.kt$CalendarViewModel : ViewModel</ID>
<ID>LongMethod:CalendarComponents.kt$@Composable fun CalendarDayTimeline( session: AuthSession, day: LocalDate, events: List&lt;CalendarEventItem&gt;, viewMode: CalendarViewMode, onViewMode: (CalendarViewMode) -&gt; Unit, onEventClick: (CalendarEventItem) -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:CalendarExtendedUi.kt$@Composable fun CalendarFullEventEditorDialog( state: CalendarUiState, onDismiss: () -&gt; Unit, onDraftChange: (CalendarEventDraft) -&gt; Unit, onSave: () -&gt; Unit, onDelete: () -&gt; Unit, onSearchAttendees: (String) -&gt; Unit, onAddAttendee: (CalendarAttendeeSuggestion) -&gt; Unit, onRemoveAttendee: (String) -&gt; Unit, onSearchLocations: (String) -&gt; Unit, onApplyLocation: (CalendarLocationSuggestion) -&gt; Unit, onOpenTalkPicker: () -&gt; Unit = {}, onPickDeviceAttachment: () -&gt; Unit = {}, onPickFilesAttachment: () -&gt; Unit = {}, onRemoveAttachment: (String) -&gt; Unit = {}, onOpenFreeBusy: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:CalendarExtendedUi.kt$@Composable fun CalendarRepeatModal( initial: RecurrenceRule, onDismiss: () -&gt; Unit, onApply: (RecurrenceRule) -&gt; Unit, onClear: () -&gt; Unit, )</ID>
<ID>LongMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, onFocusEventConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
<ID>LoopWithTooManyJumpStatements:CalendarApiClient.kt$CalendarApiClient$for</ID>
<ID>LoopWithTooManyJumpStatements:CalendarSyncEngine.kt$CalendarSyncEngine$for</ID>
<ID>MaxLineLength:CalendarApiClient.kt$CalendarApiClient$val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/location?search=${java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())}"</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Column</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.reminderMinutes.toString(), onValueChange = { v -&gt; v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Box</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$DropdownMenuItem(text = { Text("До даты") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.UNTIL, untilDate = rule.untilDate ?: LocalDate.now().plusMonths(1)); endMenu = false })</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$DropdownMenuItem(text = { Text("Никогда") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.NEVER); endMenu = false })</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$DropdownMenuItem(text = { Text("После N повторений") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.COUNT); endMenu = false })</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$EventInput(value = name, onValueChange = { name = it }, placeholder = "Название календаря", modifier = Modifier.weight(1f))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$EventPill</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$RadioButton(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Add, contentDescription = "Добавить вложение", tint = EVENT_PLACEHOLDER, modifier = Modifier.size(18.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Close, contentDescription = "Убрать доступ", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Close, contentDescription = "Удалить напоминание", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Close, contentDescription = "Удалить", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.InsertDriveFile, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.KeyboardArrowDown, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Link, contentDescription = null, tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Notifications, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$IconButton</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Row</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Комната Talk"); Switch(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Выберите беседу", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Выберите файл", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Занятость участников", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Настройки календаря", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Повторять событие", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Удалить календарь и все его события?", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(att.fileName.ifBlank { att.url.substringAfterLast('/') }, color = F7Colors.TextPrimary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(calendarName, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(entry.name, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(label, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$confirmButton = { F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving) }</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$if (isEdit) TextButton(onClick = onDelete, enabled = !state.deleting) { Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error) }</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(room.displayName, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$state.filesPickerEntries.isEmpty() -&gt; Text("Папка пуста", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$state.filesPickerLoading -&gt; Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$state.talkPickerLoading -&gt; Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$state.talkRooms.isEmpty() -&gt; Text("Нет доступных бесед", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$.</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -&gt; st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -&gt; st.copy(selectedDay = st.selectedDay.plusDays(1), visibleMonth = YearMonth.from(st.selectedDay.plusDays(1)))</ID>
@@ -142,17 +205,27 @@
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -&gt; st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.YEAR -&gt; repository.loadRange(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(deleting = false, createDialogOpen = false, eventDetail = null, focusedEventUid = null, snackMessage = "Удалено")</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь обновлён")</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь удалён")</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), eventDetail = event, focusedEventUid = event.uid)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year, datePickerOpen = false)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$private fun formatLastSync(epoch: Long)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$return if (visible.isEmpty()) _state.value.events else _state.value.events.filter { it.calendarHref in visible || it.calendarHref.isBlank() }</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$snackMessage = if (showSnack) "Синхронизация завершена (${result.pushedToDevice + result.pushedToServer + result.updated + result.linked})" else _state.value.snackMessage</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }</ID>
<ID>MaximumLineLength:CalendarApiClient.kt$CalendarApiClient$ </ID>
<ID>MaximumLineLength:CalendarComponents.kt$ </ID>
<ID>MaximumLineLength:CalendarExtendedUi.kt$ </ID>
<ID>MaximumLineLength:CalendarRepository.kt$CalendarRepository$ </ID>
<ID>MaximumLineLength:CalendarViewModel.kt$CalendarViewModel$ </ID>
<ID>MaximumLineLength:RecurrenceRule.kt$RecurrenceRule.Companion$ </ID>
<ID>MultiLineIfElse:CalendarExtendedUi.kt$draft.date.atStartOfDay(zone).toInstant().toEpochMilli()</ID>
<ID>MultiLineIfElse:CalendarExtendedUi.kt$draft.date.atTime( draft.startTime.substringBefore(':').toIntOrNull() ?: 10, draft.startTime.substringAfter(':').toIntOrNull() ?: 0, ).atZone(zone).toInstant().toEpochMilli()</ID>
<ID>MultiLineIfElse:CalendarExtendedUi.kt$endDay.atTime( draft.endTime.substringBefore(':').toIntOrNull() ?: 11, draft.endTime.substringAfter(':').toIntOrNull() ?: 0, ).atZone(zone).toInstant().toEpochMilli()</ID>
<ID>MultiLineIfElse:CalendarExtendedUi.kt$endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()</ID>
<ID>MultiLineIfElse:RecurrenceRule.kt$RecurrenceRule.Companion$""</ID>
<ID>NestedBlockDepth:CalendarApiClient.kt$CalendarApiClient$fun searchSharees(session: AuthSession, query: String): List&lt;CalendarShareeSuggestion&gt;</ID>
<ID>NoMultipleSpaces:CalendarSyncEngine.kt$CalendarSyncEngine.Companion$ </ID>
<ID>NoUnusedImports:CalendarApiClient.kt$ru.forbion.f7cloud.feature.calendar.CalendarApiClient.kt</ID>
<ID>NoUnusedImports:CalendarComponents.kt$ru.forbion.f7cloud.feature.calendar.CalendarComponents.kt</ID>
@@ -162,34 +235,41 @@
<ID>NoUnusedImports:CalendarSyncEngine.kt$ru.forbion.f7cloud.feature.calendar.CalendarSyncEngine.kt</ID>
<ID>NoUnusedImports:DeviceCalendarClient.kt$ru.forbion.f7cloud.feature.calendar.DeviceCalendarClient.kt</ID>
<ID>ParameterListWrapping:CalendarViewModel.kt$CalendarViewModel$(draft: CalendarCreateBookDraft)</ID>
<ID>StringTemplate:CalendarExtendedUi.kt$${path}</ID>
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarIconButton( iconUrl: String, contentDescription: String?, onClick: () -&gt; Unit, )</ID>
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarNavButton(text: String, onClick: () -&gt; Unit)</ID>
<ID>UnusedPrivateProperty:CalendarComponents.kt$private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)</ID>
<ID>UnusedPrivateProperty:CalendarSyncEngine.kt$CalendarSyncEngine$val mappedDevIds2 = mappings.map { it.deviceEventId }.toSet()</ID>
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Календари не найдены")</ID>
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Не удалось загрузить события календаря: $lastError")</ID>
<ID>Wrapping:CalendarComponents.kt$;</ID>
<ID>Wrapping:CalendarExtendedUi.kt$;</ID>
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onAddAttendee(s)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onApplyLocation(s)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(addTalkRoom = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(calendarHref = cal.href))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(categories = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(classification = cl))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(description = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$Text("До даты")</ID>
<ID>Wrapping:CalendarExtendedUi.kt$Text("Никогда")</ID>
<ID>Wrapping:CalendarExtendedUi.kt$Text("После N повторений")</ID>
<ID>Wrapping:CalendarExtendedUi.kt$att.url.substringAfterLast('/')</ID>
<ID>Wrapping:CalendarExtendedUi.kt$name = it</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(allDay = !draft.allDay))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(endTime = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(mode = mode))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(recurrence = preset, customRrule = ""))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(reminderMinutes = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(reminders = draft.reminders - min))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(startTime = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(subscriptionUrl = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$s.name</ID>
<ID>Wrapping:CalendarExtendedUi.kt$pickTime(draft.endTime) { onDraftChange(draft.copy(endTime = it)) }</ID>
<ID>Wrapping:CalendarExtendedUi.kt$pickTime(draft.startTime) { onDraftChange(draft.copy(startTime = it)) }</ID>
<ID>Wrapping:CalendarExtendedUi.kt$rule = rule.copy(endType = RecurrenceRule.EndType.COUNT); endMenu = false</ID>
<ID>Wrapping:CalendarExtendedUi.kt$rule = rule.copy(endType = RecurrenceRule.EndType.NEVER); endMenu = false</ID>
<ID>Wrapping:CalendarExtendedUi.kt$rule = rule.copy(endType = RecurrenceRule.EndType.UNTIL, untilDate = rule.untilDate ?: LocalDate.now().plusMonths(1)); endMenu = false</ID>
<ID>Wrapping:CalendarRepository.kt$CalendarRepository$;</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$;</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Ошибка загрузки: ${it.message}")</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось обновить: ${it.message}")</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось удалить: ${it.message}")</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(loading = false, error = it.message, unauthorized = it is UnauthorizedException)</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.calendarHref in visible || it.calendarHref.isBlank()</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl })</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.icsValue == event.classification</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$room.callUrl</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel${ _state.value = _state.value.copy(createBookOpen = false, createBookDraft = CalendarCreateBookDraft()) }</ID>
</CurrentIssues>
</SmellBaseline>
@@ -0,0 +1,208 @@
package ru.forbion.f7cloud.feature.calendar
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.ChronoField
import java.time.temporal.ChronoUnit
/**
* Локальный разворот повторяющихся событий (P1, mail/072/093).
*
* Генерирует даты-старты оккуренций правила RRULE в заданном диапазоне БЕЗ обращения к серверу
* (`<c:expand>` не используем это правка core, mail/072). Поддерживается то же подмножество,
* что умеет создавать нативный редактор ([RecurrenceRule]): FREQ=DAILY|WEEKLY|MONTHLY|YEARLY,
* INTERVAL, BYDAY (для WEEKLY), окончание UNTIL/COUNT/никогда.
*
* Развёртка делается на КАЖДЫЙ показ (не кэшируется иначе окно диапазона «вмораживается»,
* mail/072). Экземпляры различимы парой (uid, дата-старт оккуренции).
*/
object CalendarRecurrence {
/** Жёсткий предел числа шагов генератора — страховка от бесконечных/патологических правил. */
private const val MAX_STEPS = 3660
/**
* Покрывает ли событие [day] с учётом повторения.
*
* @param start дата-старт мастер-события (в нужной зоне).
* @param spanDays длительность события в днях для all-day (>=1); для timed игнорируется.
* @param allDay all-day событие занимает [occStart, occStart+spanDays); timed только occStart.
*/
fun occursOnDay(
rule: RecurrenceRule,
start: LocalDate,
day: LocalDate,
spanDays: Long,
allDay: Boolean,
): Boolean {
if (day.isBefore(start)) return false
val span = if (allDay) spanDays.coerceAtLeast(1) else 1L
// Оккуренция покрывает day, если её старт в диапазоне (day - (span-1) .. day).
val earliestStart = day.minusDays(span - 1)
var found = false
forEachOccurrenceStart(rule, start, upTo = day) { occStart ->
if (!occStart.isBefore(earliestStart) && !occStart.isAfter(day)) {
found = true
false // стоп
} else {
true // продолжаем
}
}
return found
}
/** Список дат-стартов оккуренций в [rangeStart, rangeEnd] включительно (для списковых видов). */
fun occurrenceStartsInRange(
rule: RecurrenceRule,
start: LocalDate,
rangeStart: LocalDate,
rangeEnd: LocalDate,
): List<LocalDate> {
val out = mutableListOf<LocalDate>()
forEachOccurrenceStart(rule, start, upTo = rangeEnd) { occStart ->
if (!occStart.isBefore(rangeStart)) out.add(occStart)
true
}
return out
}
/** Границы окончания правила: (UNTIL-дата или null, максимум оккуренций по COUNT). */
private fun bounds(rule: RecurrenceRule): Pair<LocalDate?, Int> {
val until = if (rule.endType == RecurrenceRule.EndType.UNTIL) rule.untilDate else null
val maxCount = if (rule.endType == RecurrenceRule.EndType.COUNT) rule.count.coerceAtLeast(1) else Int.MAX_VALUE
return until to maxCount
}
/**
* Перебирает даты-старты оккуренций от [start], пока не превышена [upTo], не исчерпан
* UNTIL/COUNT или лимит шагов. [onOccurrence] возвращает false, чтобы прервать перебор.
*/
private inline fun forEachOccurrenceStart(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
when {
rule.freq == "MONTHLY" && rule.monthlyOrdinal != null && rule.byDays.isNotEmpty() ->
forEachMonthlyNthWeekday(rule, start, upTo, onOccurrence)
rule.freq == "WEEKLY" && rule.byDays.isNotEmpty() ->
forEachWeeklyByDay(rule, start, upTo, onOccurrence)
else -> forEachSimpleStep(rule, start, upTo, onOccurrence)
}
}
/** MONTHLY с BYDAY=<n><день>: n-й (или последний при -1) день недели каждого интервал-месяца. */
private inline fun forEachMonthlyNthWeekday(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
val interval = rule.interval.coerceAtLeast(1)
val (until, maxCount) = bounds(rule)
val ordinal = rule.monthlyOrdinal ?: return
val dow = RecurrenceRule.ICS_DAYS.firstOrNull { it in rule.byDays }?.let { icsDayToDow(it) } ?: return
val baseMonth = java.time.YearMonth.from(start)
var emitted = 0
var n = 0L
while (n < MAX_STEPS) {
val month = baseMonth.plusMonths(n * interval)
val occ = nthWeekdayOfMonth(month, dow, ordinal)
n++
if (occ == null || occ.isBefore(start)) {
if (month.atEndOfMonth().isAfter(upTo)) return
continue
}
val pastUntil = until != null && occ.isAfter(until)
if (occ.isAfter(upTo) || pastUntil || emitted >= maxCount) return
emitted++
if (!onOccurrence(occ)) return
}
}
/** n-й день недели [dow] месяца [month]; ordinal>0 — с начала, -1 — последний; null если нет. */
private fun nthWeekdayOfMonth(month: java.time.YearMonth, dow: DayOfWeek, ordinal: Int): LocalDate? {
if (ordinal < 0) {
val last = month.atEndOfMonth()
val shift = (last.dayOfWeek.value - dow.value + 7) % 7
val candidate = last.minusDays(shift.toLong())
return if (java.time.YearMonth.from(candidate) == month) candidate else null
}
val first = month.atDay(1)
val shift = (dow.value - first.dayOfWeek.value + 7) % 7
val candidate = first.plusDays(shift.toLong() + (ordinal - 1) * 7L)
return if (java.time.YearMonth.from(candidate) == month) candidate else null
}
/** WEEKLY с BYDAY: раскрываем выбранные дни недели в каждом интервал-блоке недель. */
private inline fun forEachWeeklyByDay(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
val interval = rule.interval.coerceAtLeast(1)
val (until, maxCount) = bounds(rule)
val weeklyDows = RecurrenceRule.ICS_DAYS.filter { it in rule.byDays }.map { icsDayToDow(it).value.toLong() }
val weekBase = mondayOf(start)
var emitted = 0
var block = 0L
while (block < MAX_STEPS) {
val weekStart = weekBase.plusWeeks(block * interval)
if (weekStart.isAfter(upTo)) return // все дни этой и следующих недель уже за upTo
for (dowValue in weeklyDows) {
val occ = weekStart.with(ChronoField.DAY_OF_WEEK, dowValue)
if (occ.isBefore(start) || occ.isAfter(upTo)) continue
if (until != null && occ.isAfter(until)) return
if (emitted >= maxCount) return
emitted++
if (!onOccurrence(occ)) return
}
block++
}
}
/** DAILY / WEEKLY(без BYDAY) / MONTHLY / YEARLY: простой шаг по мастер-старту. */
private inline fun forEachSimpleStep(
rule: RecurrenceRule,
start: LocalDate,
upTo: LocalDate,
onOccurrence: (LocalDate) -> Boolean,
) {
val interval = rule.interval.coerceAtLeast(1)
val (until, maxCount) = bounds(rule)
var emitted = 0
var n = 0L
while (n < MAX_STEPS) {
val occ = when (rule.freq) {
"DAILY" -> start.plusDays(n * interval)
"WEEKLY" -> start.plusWeeks(n * interval)
"MONTHLY" -> start.plusMonths(n * interval)
"YEARLY" -> start.plusYears(n * interval)
else -> return
}
val pastUntil = until != null && occ.isAfter(until)
if (occ.isAfter(upTo) || pastUntil || emitted >= maxCount) return
emitted++
if (!onOccurrence(occ)) return
n++
}
}
private fun mondayOf(date: LocalDate): LocalDate = date.with(ChronoField.DAY_OF_WEEK, 1L)
private fun icsDayToDow(ics: String): DayOfWeek = when (ics) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
else -> DayOfWeek.SUNDAY
}
/** Число дней-длительности для all-day по границам [start,end) (в датах). */
fun allDaySpanDays(start: LocalDate, end: LocalDate): Long =
ChronoUnit.DAYS.between(start, end).coerceAtLeast(1)
}
@@ -430,6 +430,13 @@ class CalendarRepository(
fun eventOccursOnDay(event: CalendarEventItem, day: LocalDate, zone: ZoneId = ZoneId.systemDefault()): Boolean {
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone).toLocalDate()
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone).toLocalDate()
// Повторяющееся событие: разворачиваем оккуренции на лету (P1, mail/072/093).
val rule = RecurrenceRule.parse(event.rrule)
if (rule != null) {
val spanDays = if (event.allDay) CalendarRecurrence.allDaySpanDays(start, end) else 1L
return CalendarRecurrence.occursOnDay(rule, start, day, spanDays, event.allDay)
}
// Одиночное событие: прежнее поведение (all-day занимает [start, end)).
return if (event.allDay) !day.isBefore(start) && day.isBefore(end) else day == start
}
@@ -9,6 +9,8 @@ data class RecurrenceRule(
val interval: Int = 1,
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
val byDays: Set<String> = emptySet(),
/** Для MONTHLY с BYDAY=<n><день>: ординал (1..5 = n-й, -1 = последний). null — по числу месяца. */
val monthlyOrdinal: Int? = null,
val endType: EndType = EndType.NEVER,
val untilDate: LocalDate? = null,
val count: Int = 10,
@@ -18,7 +20,10 @@ data class RecurrenceRule(
fun toRrule(): String = buildString {
append("FREQ=$freq")
if (interval > 1) append(";INTERVAL=$interval")
if (freq == "WEEKLY" && byDays.isNotEmpty()) {
if (freq == "MONTHLY" && monthlyOrdinal != null && byDays.isNotEmpty()) {
// Месячный n-й день недели, напр. BYDAY=1MO (первый понедельник) — раньше терялся.
append(";BYDAY=$monthlyOrdinal${ICS_DAYS.first { it in byDays }}")
} else if (freq == "WEEKLY" && byDays.isNotEmpty()) {
append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}")
}
when (endType) {
@@ -35,6 +40,7 @@ data class RecurrenceRule(
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
)
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
private val BYDAY_ORDINAL = Regex("^(-?\\d+)(MO|TU|WE|TH|FR|SA|SU)$")
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */
fun parse(rrule: String): RecurrenceRule? {
@@ -50,11 +56,16 @@ data class RecurrenceRule(
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
}
val count = parts["COUNT"]?.toIntOrNull()
val bydayTokens = parts["BYDAY"]?.split(',')?.map { it.trim().uppercase() }.orEmpty()
// Ординал для MONTHLY (BYDAY=1MO/-1FR): берём из первого токена с числовым префиксом.
val monthlyOrdinal = bydayTokens
.takeIf { freq == "MONTHLY" }
?.firstNotNullOfOrNull { BYDAY_ORDINAL.find(it)?.groupValues?.get(1)?.toIntOrNull() }
return RecurrenceRule(
freq = freq,
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() }
?.filter { it in ICS_DAYS }?.toSet().orEmpty(),
byDays = bydayTokens.map { it.takeLast(2) }.filter { it in ICS_DAYS }.toSet(),
monthlyOrdinal = monthlyOrdinal,
endType = when {
until != null -> EndType.UNTIL
count != null -> EndType.COUNT
@@ -0,0 +1,117 @@
package ru.forbion.f7cloud.feature.calendar
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
/**
* Разворот повторений (P1): [CalendarRecurrence.occursOnDay] / [occurrenceStartsInRange].
*/
class CalendarRecurrenceTest {
private fun rule(s: String) = RecurrenceRule.parse(s)!!
private fun d(s: String) = LocalDate.parse(s)
private fun occurs(rrule: String, start: String, day: String, span: Long = 1, allDay: Boolean = false) =
CalendarRecurrence.occursOnDay(rule(rrule), d(start), d(day), span, allDay)
@Test fun daily_hits_every_day() {
assertTrue(occurs("FREQ=DAILY", "2026-07-01", "2026-07-01"))
assertTrue(occurs("FREQ=DAILY", "2026-07-01", "2026-07-15"))
assertFalse("до старта — нет", occurs("FREQ=DAILY", "2026-07-10", "2026-07-05"))
}
@Test fun daily_interval_2_skips_odd_offsets() {
assertTrue(occurs("FREQ=DAILY;INTERVAL=2", "2026-07-01", "2026-07-03"))
assertFalse(occurs("FREQ=DAILY;INTERVAL=2", "2026-07-01", "2026-07-02"))
}
@Test fun weekly_same_weekday() {
// 2026-07-01 — среда; +неделя = 08, 15…
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-08"))
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-29"))
assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-09"))
}
@Test fun weekly_byday_mon_wed_fri() {
// старт среда 2026-07-01; BYDAY=MO,WE,FR
val r = "FREQ=WEEKLY;BYDAY=MO,WE,FR"
assertTrue("среда старта", occurs(r, "2026-07-01", "2026-07-01"))
assertTrue("пятница той же недели", occurs(r, "2026-07-01", "2026-07-03"))
assertTrue("понедельник след. недели", occurs(r, "2026-07-01", "2026-07-06"))
assertFalse("вторник — нет", occurs(r, "2026-07-01", "2026-07-07"))
assertFalse("понедельник ДО старта той же недели — нет", occurs(r, "2026-07-01", "2026-06-29"))
}
@Test fun weekly_byday_interval_2_skips_middle_week() {
val r = "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO"
// старт среда 01.07; понедельники: блок0 нед. 29.06 (до старта, нет), блок2 нед. 13.07 → 13
assertTrue(occurs(r, "2026-07-01", "2026-07-13"))
assertFalse("промежуточная неделя 06.07 пропущена", occurs(r, "2026-07-01", "2026-07-06"))
}
@Test fun monthly_same_day_of_month() {
assertTrue(occurs("FREQ=MONTHLY", "2026-01-15", "2026-04-15"))
assertFalse(occurs("FREQ=MONTHLY", "2026-01-15", "2026-04-16"))
}
@Test fun yearly() {
assertTrue(occurs("FREQ=YEARLY", "2024-02-29", "2028-02-29"))
assertTrue(occurs("FREQ=YEARLY", "2026-03-10", "2030-03-10"))
assertFalse(occurs("FREQ=YEARLY", "2026-03-10", "2027-03-11"))
}
@Test fun count_limits_occurrences() {
// COUNT=3 → 01, 02, 03; 04 уже нет
assertTrue(occurs("FREQ=DAILY;COUNT=3", "2026-07-01", "2026-07-03"))
assertFalse(occurs("FREQ=DAILY;COUNT=3", "2026-07-01", "2026-07-04"))
}
@Test fun until_bounds_occurrences() {
assertTrue(occurs("FREQ=DAILY;UNTIL=20260705T235959Z", "2026-07-01", "2026-07-05"))
assertFalse(occurs("FREQ=DAILY;UNTIL=20260705T235959Z", "2026-07-01", "2026-07-06"))
}
@Test fun allday_multiday_occurrence_covers_span() {
// 2-дневное all-day (span=2), еженедельно: оккуренция 08.07 покрывает 08 и 09
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-09", span = 2, allDay = true))
assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-10", span = 2, allDay = true))
}
@Test fun monthly_nth_weekday_first_monday() {
// первый понедельник: июль'26 = 06, авг = 03, сен = 07
val r = "FREQ=MONTHLY;BYDAY=1MO"
assertTrue(occurs(r, "2026-07-06", "2026-07-06"))
assertTrue(occurs(r, "2026-07-06", "2026-08-03"))
assertTrue(occurs(r, "2026-07-06", "2026-09-07"))
assertFalse("второй понедельник — нет", occurs(r, "2026-07-06", "2026-07-13"))
assertFalse("число месяца, не день недели", occurs(r, "2026-07-06", "2026-08-06"))
}
@Test fun monthly_last_friday() {
// последняя пятница: июль'26 = 31, авг = 28
val r = "FREQ=MONTHLY;BYDAY=-1FR"
assertTrue(occurs(r, "2026-07-31", "2026-07-31"))
assertTrue(occurs(r, "2026-07-31", "2026-08-28"))
assertFalse(occurs(r, "2026-07-31", "2026-08-21"))
}
@Test fun byday_ordinal_round_trips_through_rrule() {
// раньше toRrule терял ординал (1MO → MO); теперь сохраняет.
assertEquals("FREQ=MONTHLY;BYDAY=1MO", rule("FREQ=MONTHLY;BYDAY=1MO").toRrule())
assertEquals("FREQ=MONTHLY;BYDAY=-1FR", rule("FREQ=MONTHLY;BYDAY=-1FR").toRrule())
assertEquals(1, rule("FREQ=MONTHLY;BYDAY=1MO").monthlyOrdinal)
}
@Test fun range_listing() {
val starts = CalendarRecurrence.occurrenceStartsInRange(
rule = rule("FREQ=WEEKLY"),
start = d("2026-07-01"),
rangeStart = d("2026-07-05"),
rangeEnd = d("2026-07-25"),
)
assertEquals(listOf(d("2026-07-08"), d("2026-07-15"), d("2026-07-22")), starts)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force &amp;&amp; !needsPhotoBackfill &amp;&amp; !needsDetailsBackfill &amp;&amp; System.currentTimeMillis() - lastSync &lt; SYNC_INTERVAL_MS</ID>
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -&gt; Unit, )</ID>
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -&gt; Unit, onEdit: () -&gt; Unit, onDelete: () -&gt; Unit, deleting: Boolean, )</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$"https://$it"</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
</CurrentIssues>
@@ -17,14 +17,20 @@ 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.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -42,6 +48,9 @@ import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
fun ContactDetailSheet(
contact: ContactItem,
onDismiss: () -> Unit,
onEdit: () -> Unit = {},
onDelete: () -> Unit = {},
deleting: Boolean = false,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
@@ -52,6 +61,9 @@ fun ContactDetailSheet(
ContactDetailContent(
contact = contact,
onDismiss = onDismiss,
onEdit = onEdit,
onDelete = onDelete,
deleting = deleting,
)
}
}
@@ -60,8 +72,13 @@ fun ContactDetailSheet(
private fun ContactDetailContent(
contact: ContactItem,
onDismiss: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
deleting: Boolean,
) {
val context = LocalContext.current
// Правка/удаление доступны только для синхронизированных контактов (есть href/raw).
val editable = contact.href.isNotBlank() && contact.raw.isNotBlank()
val label = contact.displayName.ifBlank { contact.email }
val photoBytes = remember(contact.uid, contact.photoBase64) {
ContactUi.decodeContactPhoto(contact.photoBase64)
@@ -163,31 +180,27 @@ private fun ContactDetailContent(
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
if (contact.emailLines.isNotEmpty()) {
contact.emailLines.forEachIndexed { index, email ->
ContactDetailField(
label = if (index == 0) "Email" else "Email ${index + 1}",
value = email,
onClick = {
context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")),
)
},
)
}
contact.emailEntries.forEachIndexed { index, entry ->
ContactDetailField(
label = ContactUi.emailTypeLabel(entry.type, index),
value = entry.value,
onClick = {
context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${entry.value}")),
)
},
)
}
if (contact.phoneLines.isNotEmpty()) {
contact.phoneLines.forEachIndexed { index, phone ->
ContactDetailField(
label = if (index == 0) "Телефон" else "Телефон ${index + 1}",
value = phone,
onClick = {
context.startActivity(
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")),
)
},
)
}
contact.phoneEntries.forEachIndexed { index, entry ->
ContactDetailField(
label = ContactUi.phoneTypeLabel(entry.type, index),
value = entry.value,
onClick = {
context.startActivity(
Intent(Intent.ACTION_DIAL, Uri.parse("tel:${entry.value}")),
)
},
)
}
if (contact.address.isNotBlank()) {
ContactDetailField(label = "Адрес", value = contact.address)
@@ -213,6 +226,15 @@ private fun ContactDetailContent(
}
}
if (editable) {
ContactDetailActions(
contact = contact,
deleting = deleting,
onEdit = onEdit,
onDelete = onDelete,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Закрыть",
@@ -225,6 +247,65 @@ private fun ContactDetailContent(
}
}
@Composable
private fun ContactDetailActions(
contact: ContactItem,
deleting: Boolean,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
var confirmDelete by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
F7SecondaryButton(
text = "Править",
onClick = onEdit,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(100.dp))
.clickable(enabled = !deleting) { confirmDelete = true }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
if (deleting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = F7Colors.Error,
strokeWidth = 2.dp,
)
} else {
Text("Удалить", color = F7Colors.Error, fontWeight = FontWeight.SemiBold)
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Удалить контакт?") },
text = {
Text("«${contact.displayName.ifBlank { contact.email }}» будет удалён без возможности отмены.")
},
confirmButton = {
TextButton(
onClick = {
confirmDelete = false
onDelete()
},
) { Text("Удалить", color = F7Colors.Error) }
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
},
)
}
}
@Composable
private fun ContactDetailField(
label: String,
@@ -287,4 +368,21 @@ internal object ContactUi {
android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
}.getOrNull()?.takeIf { it.isNotEmpty() }
}
// P2: подпись типа EMAIL/TEL по токену TYPE (WORK/HOME/CELL/…); index — фолбэк-нумерация.
fun emailTypeLabel(type: String, index: Int): String = when (type.uppercase()) {
"WORK" -> "Рабочий"
"HOME" -> "Личный"
"" -> if (index == 0) "Email" else "Email ${index + 1}"
else -> type.lowercase().replaceFirstChar { it.uppercase() }
}
fun phoneTypeLabel(type: String, index: Int): String = when (type.uppercase()) {
"CELL", "MOBILE" -> "Мобильный"
"WORK" -> "Рабочий"
"HOME" -> "Домашний"
"FAX" -> "Факс"
"" -> if (index == 0) "Телефон" else "Телефон ${index + 1}"
else -> type.lowercase().replaceFirstChar { it.uppercase() }
}
}
@@ -9,6 +9,8 @@ import ru.forbion.f7cloud.core.database.ContactEntity
import ru.forbion.f7cloud.core.database.F7Database
import ru.forbion.f7cloud.core.network.CardDavClient
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.VCardFields
import ru.forbion.f7cloud.core.network.VCardTypedValue
data class ContactItem(
val uid: String,
@@ -25,6 +27,10 @@ data class ContactItem(
val birthday: String = "",
val emails: String = "",
val phones: String = "",
// Для правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
val href: String = "",
val etag: String = "",
val raw: String = "",
) {
val emailLines: List<String>
get() = (if (emails.isNotBlank()) emails else email)
@@ -40,6 +46,21 @@ data class ContactItem(
.filter { it.isNotBlank() }
.distinct()
// P2: значения с типом (WORK/HOME/CELL) из сырого vCard; фолбэк на значения без типа.
val emailEntries: List<VCardTypedValue>
get() = if (raw.isNotBlank()) {
CardDavClient.parseTypedValues(raw, "EMAIL")
} else {
emailLines.map { VCardTypedValue(it) }
}
val phoneEntries: List<VCardTypedValue>
get() = if (raw.isNotBlank()) {
CardDavClient.parseTypedValues(raw, "TEL")
} else {
phoneLines.map { VCardTypedValue(it) }
}
val subtitle: String?
get() = when {
title.isNotBlank() && organization.isNotBlank() -> "$title · $organization"
@@ -104,6 +125,9 @@ class ContactsRepository(context: Context) {
birthday = contact.birthday,
emails = contact.emails,
phones = contact.phones,
href = contact.href,
etag = contact.etag,
raw = contact.raw,
)
}
dao.replaceAll(key, entities)
@@ -147,6 +171,62 @@ class ContactsRepository(context: Context) {
return entity.toItem()
}
/**
* Правка контакта: round-trip сырого vCard (сохраняет PHOTO/X-*), PUT If-Match по ETag.
* Требует, чтобы контакт был синхронизирован (есть href/raw). Обновляет Room новым состоянием.
*/
suspend fun updateContact(
session: AuthSession,
item: ContactItem,
fields: VCardFields,
): ContactItem {
if (item.href.isBlank() || item.raw.isBlank()) {
error("Контакт ещё не синхронизирован — обновите список и повторите")
}
val client = authedClient(session)
val updated = CardDavClient.updateContact(
client = client,
href = item.href,
etag = item.etag,
rawVCard = item.raw,
fields = fields,
bookName = item.bookName,
)
val key = accountKey(session)
val entity = ContactEntity(
accountKey = key,
uid = updated.uid.ifBlank { item.uid },
displayName = updated.displayName,
email = updated.email,
phone = updated.phone,
bookName = updated.bookName.ifBlank { item.bookName },
photoBase64 = updated.photoBase64,
photoMimeType = updated.photoMimeType,
organization = updated.organization,
title = updated.title,
address = updated.address,
website = updated.website,
birthday = updated.birthday,
emails = updated.emails,
phones = updated.phones,
href = updated.href.ifBlank { item.href },
etag = updated.etag,
raw = updated.raw,
)
dao.insert(entity)
return entity.toItem()
}
/** Удаление контакта (DELETE If-Match) + удаление из Room-кэша. */
suspend fun deleteContact(session: AuthSession, item: ContactItem) {
if (item.href.isBlank()) {
error("Контакт ещё не синхронизирован — обновите список и повторите")
}
val client = authedClient(session)
CardDavClient.deleteContact(client, item.href, item.etag)
dao.deleteByUid(accountKey(session), item.uid)
}
fun filterSuggestions(
contacts: List<ContactItem>,
query: String,
@@ -224,6 +304,9 @@ class ContactsRepository(context: Context) {
birthday = birthday,
emails = emails,
phones = phones,
href = href,
etag = etag,
raw = raw,
)
companion object {
@@ -15,15 +15,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -52,6 +54,8 @@ import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.network.VCardFields
import ru.forbion.f7cloud.core.network.VCardTypedValue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -121,7 +125,19 @@ fun ContactsScreen(
ContactDetailSheet(
contact = contact,
onDismiss = vm::closeContactDetail,
onEdit = vm::openEditSheet,
onDelete = { vm.deleteContact(session, contact) },
deleting = state.deletingContact,
)
if (state.editSheetOpen) {
EditContactDialog(
contact = contact,
saving = state.savingEdit,
error = state.editError,
onDismiss = vm::closeEditSheet,
onSave = { fields -> vm.saveEdit(session, contact, fields) },
)
}
}
if (state.addSheetOpen) {
@@ -136,6 +152,114 @@ fun ContactsScreen(
}
}
@Composable
private fun EditContactDialog(
contact: ContactItem,
saving: Boolean,
error: String?,
onDismiss: () -> Unit,
onSave: (VCardFields) -> Unit,
) {
var name by remember(contact.uid) { mutableStateOf(contact.displayName) }
var organization by remember(contact.uid) { mutableStateOf(contact.organization) }
var title by remember(contact.uid) { mutableStateOf(contact.title) }
var emails by remember(contact.uid) { mutableStateOf(contact.emailLines.joinToString("\n")) }
var phones by remember(contact.uid) { mutableStateOf(contact.phoneLines.joinToString("\n")) }
var address by remember(contact.uid) { mutableStateOf(contact.address) }
var website by remember(contact.uid) { mutableStateOf(contact.website) }
var birthday by remember(contact.uid) { mutableStateOf(contact.birthday) }
// Типы сохраняем по значению: неизменённые адреса/номера не теряют TYPE (fix P1-каветата).
val emailTypeByValue = contact.emailEntries.associate { it.value to it.type }
val phoneTypeByValue = contact.phoneEntries.associate { it.value to it.type }
fun buildFields(): VCardFields {
fun lines(v: String) = v.lines().map { it.trim() }.filter { it.isNotBlank() }
return VCardFields(
displayName = name.trim(),
emails = lines(emails).map { VCardTypedValue(it, emailTypeByValue[it].orEmpty()) },
phones = lines(phones).map { VCardTypedValue(it, phoneTypeByValue[it].orEmpty()) },
organization = organization.trim(),
title = title.trim(),
address = address.trim(),
website = website.trim(),
birthday = birthday.trim(),
)
}
Dialog(onDismissRequest = { if (!saving) onDismiss() }) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
.padding(20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"Править контакт",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
AddContactField(label = "Имя", value = name, onValueChange = { name = it })
AddContactField(label = "Организация", value = organization, onValueChange = { organization = it })
AddContactField(label = "Должность", value = title, onValueChange = { title = it })
AddContactField(
label = "Email (по одному в строке)",
value = emails,
onValueChange = { emails = it },
singleLine = false,
)
AddContactField(
label = "Телефон (по одному в строке)",
value = phones,
onValueChange = { phones = it },
singleLine = false,
)
AddContactField(label = "Адрес", value = address, onValueChange = { address = it })
AddContactField(label = "Сайт", value = website, onValueChange = { website = it })
AddContactField(label = "День рождения", value = birthday, onValueChange = { birthday = it })
if (!error.isNullOrBlank()) {
Text(error, color = MaterialTheme.colorScheme.error, fontSize = 13.sp)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Отмена",
modifier = Modifier
.clickable(enabled = !saving, onClick = onDismiss)
.padding(8.dp),
color = F7Colors.TextSecondary,
)
Spacer(Modifier.size(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary)
.clickable(enabled = !saving) { onSave(buildFields()) }
.padding(horizontal = 20.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
if (saving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text("Сохранить", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
}
@Composable
private fun AddContactDialog(
saving: Boolean,
@@ -209,6 +333,7 @@ private fun AddContactField(
label: String,
value: String,
onValueChange: (String) -> Unit,
singleLine: Boolean = true,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
@@ -220,7 +345,7 @@ private fun AddContactField(
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
singleLine = true,
singleLine = singleLine,
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
)
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.VCardFields
data class ContactsUiState(
val loading: Boolean = false,
@@ -28,6 +29,10 @@ data class ContactsUiState(
val savingContact: Boolean = false,
val addError: String? = null,
val selectedContact: ContactItem? = null,
val editSheetOpen: Boolean = false,
val savingEdit: Boolean = false,
val editError: String? = null,
val deletingContact: Boolean = false,
) {
val filteredContacts: List<ContactItem>
get() {
@@ -70,6 +75,70 @@ class ContactsViewModel(
_state.update { it.copy(selectedContact = null) }
}
fun openEditSheet() {
_state.update { it.copy(editSheetOpen = true, editError = null) }
}
fun closeEditSheet() {
_state.update { it.copy(editSheetOpen = false, editError = null, savingEdit = false) }
}
/** Сохранение правки контакта: round-trip vCard (If-Match). fields собирает форма. */
fun saveEdit(session: AuthSession, item: ContactItem, fields: VCardFields) {
if (_state.value.savingEdit) return
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(savingEdit = true, editError = null) }
runCatching { repository.updateContact(session, item, fields) }
.onSuccess { updated ->
_state.update {
it.copy(
savingEdit = false,
editSheetOpen = false,
editError = null,
selectedContact = updated,
)
}
refresh(session, showLoading = false)
}
.onFailure { t ->
_state.update {
it.copy(
savingEdit = false,
editError = t.message ?: "Не удалось сохранить контакт",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
/** Удаление контакта (If-Match) + закрытие карточки. */
fun deleteContact(session: AuthSession, item: ContactItem) {
if (_state.value.deletingContact) return
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(deletingContact = true, editError = null) }
runCatching { repository.deleteContact(session, item) }
.onSuccess {
_state.update {
it.copy(
deletingContact = false,
selectedContact = null,
editSheetOpen = false,
)
}
}
.onFailure { t ->
_state.update {
it.copy(
deletingContact = false,
editError = t.message ?: "Не удалось удалить контакт",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun load(session: AuthSession) {
if (activeSession?.serverUrl == session.serverUrl &&
activeSession?.username == session.username
+13
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
id 'io.github.takahirom.roborazzi'
}
android {
@@ -21,6 +22,12 @@ android {
kotlinOptions {
jvmTarget = '17'
}
// Roborazzi/Robolectric: рендер Compose deck-экранов в PNG на JVM (Fd-сверка, mail/111).
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
@@ -35,4 +42,10 @@ dependencies {
implementation libs.compose.material3
implementation libs.compose.foundation
implementation libs.lifecycle.viewmodel.compose
testImplementation libs.robolectric
testImplementation libs.roborazzi
testImplementation libs.roborazzi.compose
testImplementation libs.roborazzi.junit.rule
testImplementation libs.compose.ui.test.junit4
debugImplementation libs.compose.ui.test.manifest
}
+31 -1
View File
@@ -2,6 +2,36 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NoUnusedImports:DeckRepository.kt$ru.forbion.f7cloud.feature.deck.DeckRepository.kt</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$("В архив", onClick = onArchive, enabled = !busy, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$("Удалить", onClick = onDelete, enabled = !busy, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(1f)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(6.dp)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(8.dp)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(label.title, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(stack.title, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(value = description, onValueChange = { description = it }, label = "Описание", minLines = 3)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(", ")</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(18.dp)</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(4.dp)</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp))</ID>
<ID>ArgumentListWrapping:DeckViewModel.kt$DeckViewModel$(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false)</ID>
<ID>CyclomaticComplexMethod:DeckCardSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckCardSheet( card: DeckCard, boardLabels: List&lt;DeckLabel&gt;, stacks: List&lt;DeckStack&gt;, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -&gt; Unit, onToggleDone: () -&gt; Unit, onSave: (title: String, description: String, duedate: String?) -&gt; Unit, onToggleLabel: (DeckLabel) -&gt; Unit, onToggleAssignSelf: () -&gt; Unit, onMove: (targetStackId: Int) -&gt; Unit, onArchive: () -&gt; Unit, onDelete: () -&gt; Unit, )</ID>
<ID>LongMethod:DeckCardSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckCardSheet( card: DeckCard, boardLabels: List&lt;DeckLabel&gt;, stacks: List&lt;DeckStack&gt;, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -&gt; Unit, onToggleDone: () -&gt; Unit, onSave: (title: String, description: String, duedate: String?) -&gt; Unit, onToggleLabel: (DeckLabel) -&gt; Unit, onToggleAssignSelf: () -&gt; Unit, onMove: (targetStackId: Int) -&gt; Unit, onArchive: () -&gt; Unit, onDelete: () -&gt; Unit, )</ID>
<ID>LoopWithTooManyJumpStatements:DeckRepository.kt$DeckRepository$for</ID>
<ID>MaxLineLength:DeckCardSheet.kt$Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })</ID>
<ID>MaxLineLength:DeckScreen.kt$Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
<ID>MaxLineLength:DeckScreen.kt$Text("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>MaximumLineLength:DeckCardSheet.kt$ </ID>
<ID>MaximumLineLength:DeckScreen.kt$ </ID>
<ID>MaximumLineLength:DeckViewModel.kt$DeckViewModel$ </ID>
<ID>NoConsecutiveBlankLines:DeckCardSheet.kt$ </ID>
<ID>NoUnusedImports:DeckScreen.kt$ru.forbion.f7cloud.feature.deck.DeckScreen.kt</ID>
<ID>Wrapping:DeckCardSheet.kt$duedate = null</ID>
<ID>Wrapping:DeckScreen.kt$;</ID>
</CurrentIssues>
</SmellBaseline>
@@ -43,9 +43,13 @@ 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.F7SecondaryButton
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -120,6 +124,17 @@ fun DeckCardSheet(
Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })
}
}
if (canEdit) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
DeckDueChip("Сегодня") { duedate = deckQuickDueIso(0) }
DeckDueChip("Завтра") { duedate = deckQuickDueIso(1) }
DeckDueChip("Выходные") { duedate = deckQuickDueIso(0, toWeekend = true) }
DeckDueChip("Неделя") { duedate = deckQuickDueIso(7) }
}
}
// Метки доски
if (boardLabels.isNotEmpty()) {
@@ -236,8 +251,9 @@ fun DeckTextPromptDialog(
confirmText: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
initial: String = "",
) {
var text by remember { mutableStateOf("") }
var text by remember { mutableStateOf(initial) }
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -263,6 +279,56 @@ fun DeckTextPromptDialog(
}
@Composable
fun DeckConfirmDialog(
title: String,
message: String,
confirmText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Text(message, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(text = confirmText, onClick = onConfirm, modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun DeckDueChip(text: String, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.SurfaceMuted)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
/** ISO-срок для быстрых опций: сегодня/через N дней/ближайшая суббота (полночь UTC — как пикер). */
private fun deckQuickDueIso(daysAhead: Long, toWeekend: Boolean = false): String {
val date = if (toWeekend) {
LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
} else {
LocalDate.now().plusDays(daysAhead)
}
return DateTimeFormatter.ISO_INSTANT.format(date.atStartOfDay(ZoneOffset.UTC).toInstant())
}
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
Instant.parse(iso).toEpochMilli()
@@ -100,6 +100,7 @@ class DeckRepository {
labels = parseLabels(card.optJSONArray("labels")),
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
assigneeUids = parseAssigneeUids(card.optJSONArray("assignedUsers")),
commentsCount = card.optInt("commentsCount", 0),
)
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
@@ -214,6 +215,15 @@ class DeckRepository {
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList())
}
fun renameStack(session: AuthSession, stack: DeckStack, title: String) {
val body = JSONObject().put("title", title).put("boardId", stack.boardId).put("order", stack.order)
sendJson(session, "PUT", "${apiBase(session)}/stacks/${stack.id}", body)
}
fun deleteStack(session: AuthSession, stackId: Int) {
sendJson(session, "DELETE", "${apiBase(session)}/stacks/$stackId", null)
}
// --- HTTP ---
private fun getJson(client: OkHttpClient, url: String): Any {
@@ -287,6 +297,7 @@ data class DeckCard(
val labels: List<DeckLabel> = emptyList(),
val assignees: List<String> = emptyList(),
val assigneeUids: List<String> = emptyList(),
val commentsCount: Int = 0,
)
data class DeckBoardDetail(
@@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -62,6 +65,8 @@ fun DeckScreen(
val state by vm.state.collectAsState()
var addStackOpen by remember { mutableStateOf(false) }
var addCardStackId by remember { mutableStateOf<Int?>(null) }
var renameStackTarget by remember { mutableStateOf<DeckStack?>(null) }
var deleteStackTarget by remember { mutableStateOf<DeckStack?>(null) }
LaunchedEffect(session.serverUrl, session.username) { vm.load(session) }
LaunchedEffect(openCardId) {
@@ -110,6 +115,8 @@ fun DeckScreen(
onCardClick = { vm.openCard(it) },
onToggleDone = { vm.toggleDone(session, it) },
onAddCard = { addCardStackId = stack.id },
onRename = { renameStackTarget = stack },
onDelete = { deleteStackTarget = stack },
)
}
}
@@ -165,10 +172,48 @@ fun DeckScreen(
onDismiss = { addCardStackId = null },
)
}
DeckStackDialogs(
renameTarget = renameStackTarget,
deleteTarget = deleteStackTarget,
onRenameConfirm = { stack, title -> vm.renameStack(session, stack, title); renameStackTarget = null },
onDeleteConfirm = { stack -> vm.deleteStack(session, stack); deleteStackTarget = null },
onDismissRename = { renameStackTarget = null },
onDismissDelete = { deleteStackTarget = null },
)
}
@Composable
private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
private fun DeckStackDialogs(
renameTarget: DeckStack?,
deleteTarget: DeckStack?,
onRenameConfirm: (DeckStack, String) -> Unit,
onDeleteConfirm: (DeckStack) -> Unit,
onDismissRename: () -> Unit,
onDismissDelete: () -> Unit,
) {
renameTarget?.let { stack ->
DeckTextPromptDialog(
title = "Переименовать колонку",
label = "Название колонки",
confirmText = "Сохранить",
initial = stack.title,
onConfirm = { onRenameConfirm(stack, it) },
onDismiss = onDismissRename,
)
}
deleteTarget?.let { stack ->
DeckConfirmDialog(
title = "Удалить колонку?",
message = "Колонка «${stack.title}» и её карточки будут удалены.",
confirmText = "Удалить",
onConfirm = { onDeleteConfirm(stack) },
onDismiss = onDismissDelete,
)
}
}
@Composable
internal fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -200,13 +245,16 @@ private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DeckStackSection(
internal fun DeckStackSection(
stack: DeckStack,
canEdit: Boolean,
onCardClick: (DeckCard) -> Unit,
onToggleDone: (DeckCard) -> Unit,
onAddCard: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -240,6 +288,28 @@ private fun DeckStackSection(
) {
Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))
}
Box {
Icon(
Icons.Filled.MoreVert,
contentDescription = "Меню колонки",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable { menuOpen = true }
.padding(5.dp),
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Переименовать") },
onClick = { menuOpen = false; onRename() },
)
DropdownMenuItem(
text = { Text("Удалить список") },
onClick = { menuOpen = false; onDelete() },
)
}
}
}
}
stack.cards.forEach { card ->
@@ -292,7 +362,9 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
)
}
val due = card.duedate?.let { formatDeckDue(it) }
if (due != null || card.assignees.isNotEmpty()) {
val (checked, total) = deckChecklistCounts(card.description)
val hasMeta = listOf(due != null, total > 0, card.commentsCount > 0, card.assignees.isNotEmpty())
if (hasMeta.any { it }) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -301,6 +373,14 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
if (due != null) {
Text("🗓 $due", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
}
if (total > 0) {
val todo = "$checked/$total"
Text(todo, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
}
if (card.commentsCount > 0) {
val comments = "💬 ${card.commentsCount}"
Text(comments, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
}
if (card.assignees.isNotEmpty()) {
Text("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@@ -329,6 +409,17 @@ internal fun DeckLabelChip(label: DeckLabel) {
}
}
private val deckChecklistRe = Regex("(?im)^\\s*([*+-]|\\d\\.)\\s+\\[\\s*(\\s|[xX])\\s*\\].*$")
private val deckCheckedRe = Regex("(?im)^\\s*([*+-]|\\d\\.)\\s+\\[\\s*[xX]\\s*\\].*$")
/** Чек-пункты из описания карточки (как веб CardBadges): (отмечено, всего). */
internal fun deckChecklistCounts(description: String): Pair<Int, Int> {
if (description.isBlank()) return 0 to 0
val total = deckChecklistRe.findAll(description).count()
val checked = deckCheckedRe.findAll(description).count()
return checked to total
}
/** Цвет Deck («RRGGBB» без #, иногда с #). */
internal fun parseDeckColor(hex: String?): Color? {
val h = hex?.trim()?.removePrefix("#")?.takeIf { it.length == 6 } ?: return null
@@ -124,6 +124,12 @@ class DeckViewModel(
mutate(session) { repository.createStack(session, boardId, title.trim()) }
}
fun renameStack(session: AuthSession, stack: DeckStack, title: String) =
mutate(session) { repository.renameStack(session, stack, title.trim()) }
fun deleteStack(session: AuthSession, stack: DeckStack) =
mutate(session) { repository.deleteStack(session, stack.id) }
private fun mutate(
session: AuthSession,
closeCardAfter: Boolean = false,
@@ -0,0 +1,101 @@
package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import ru.forbion.f7cloud.core.designsystem.F7Theme
/**
* Fd-сверка Карточек (Roborazzi, mail/111): рендер экранов в PNG на JVM (360dp, без эмулятора).
* Данные только мок (`bmob-*`), реальные данные `cloude` не используются.
* Сравнение PNG веб-эталон (`docs/app-map/reference/deck/`) пункты Д в `deck.diff.md`.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w360dp-h800dp-xxhdpi")
class DeckScreenshotTest {
@get:Rule
val composeRule = createComposeRule()
private val labels = listOf(
DeckLabel(id = 1, title = "bmob-важное", color = "e74c3c"),
DeckLabel(id = 2, title = "bmob-ревью", color = "3498db"),
)
private val mockStack = DeckStack(
id = 1,
boardId = 1,
title = "В работе",
order = 0,
cards = listOf(
DeckCard(
id = 1,
title = "bmob-карточка со сроком и метками",
done = false,
description = "- [x] шаг 1\n- [ ] шаг 2",
duedate = "2026-07-20T00:00:00+00:00",
order = 0,
stackId = 1,
labels = labels,
assignees = listOf("bmob-user"),
assigneeUids = listOf("bmob-user"),
commentsCount = 3,
),
DeckCard(id = 2, title = "bmob-выполненная", done = true, stackId = 1, order = 1),
),
)
@Test
fun deck_boards_list() {
composeRule.setContent {
F7Theme {
Column(
modifier = Modifier.fillMaxSize().background(Color.White).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
DeckBoardRow(DeckBoard(id = 1, title = "bmob-доска", color = "9b59b6")) {}
DeckBoardRow(DeckBoard(id = 2, title = "bmob-проект", color = "2ecc71")) {}
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/deck-boards.png")
}
@Test
fun deck_board_with_stack() {
composeRule.setContent {
F7Theme {
Column(
modifier = Modifier.fillMaxSize().background(Color.White).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
DeckStackSection(
stack = mockStack,
canEdit = true,
onCardClick = {},
onToggleDone = {},
onAddCard = {},
onRename = {},
onDelete = {},
)
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/deck-board.png")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -131,7 +131,7 @@ fun FilesBreadcrumb(
}
}
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (пилюли 25dp, minWidth 86). */
/** Ряд фильтр-чипов: Тип файла / Изменён / Участники (порядок и «ё» — как в моб.вебе forbion). */
@Composable
fun FilesFilterChips(
onTypeClick: () -> Unit,
@@ -148,8 +148,8 @@ fun FilesFilterChips(
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip("Тип файла", onTypeClick)
FilterChip("Изменён", onModifiedClick)
FilterChip("Участники", onParticipantsClick)
FilterChip("Изменен", onModifiedClick)
}
}
@@ -0,0 +1,64 @@
package ru.forbion.f7cloud.feature.notes
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import kotlinx.coroutines.delay
import ru.forbion.f7cloud.core.designsystem.F7HeaderActionButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
private const val AUTOSAVE_DEBOUNCE_MS = 1200L
/**
* Редактор заметки (P1, простой markdown). Автосохранение с debounce PUT с `If-Match`
* (конфликт версий обрабатывает [NotesViewModel]). Rich-режим/preview/полноэкранный P2
* (карта app-map/notes.md).
*/
@Composable
fun NoteEditorScreen(
note: Note,
saving: Boolean,
onContentSave: (String) -> Unit,
onToggleFavorite: () -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
var content by remember(note.id) { mutableStateOf(note.content) }
// Автосейв: сохраняем через паузу после последнего изменения, если текст отличается.
LaunchedEffect(content) {
if (content != note.content) {
delay(AUTOSAVE_DEBOUNCE_MS)
onContentSave(content)
}
}
F7ModuleScreen(
title = note.title.ifBlank { "Новая заметка" },
modifier = modifier,
loading = false,
headerActions = {
F7HeaderActionButton(text = if (note.favorite) "" else "", onClick = onToggleFavorite)
F7HeaderActionButton(text = "Удалить", onClick = onDelete)
F7HeaderActionButton(text = if (saving) "Сохранение…" else "Готово", onClick = {
if (content != note.content) onContentSave(content)
onBack()
}, enabled = !saving)
},
) {
F7OutlinedField(
value = content,
onValueChange = { if (!note.readonly) content = it },
label = if (note.readonly) "Только для чтения" else "Заметка",
minLines = 12,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -0,0 +1,154 @@
package ru.forbion.f7cloud.feature.notes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ListCard
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7SolidPrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7Spacing
/**
* Экран «Заметки» (P1): список с поиском и пустым состоянием, открытие заметки в редакторе,
* диалог конфликта версий. Состав/поведение по карте app-map/notes.md.
*/
@Composable
fun NotesScreen(
session: AuthSession,
modifier: Modifier = Modifier,
createRequest: Int = 0,
onUnauthorized: () -> Unit = {},
) {
val context = LocalContext.current
val vm: NotesViewModel = viewModel(factory = NotesViewModel.Factory(context))
val state by vm.state.collectAsState()
LaunchedEffect(session.serverUrl, session.username, session.davUserId) { vm.load(session) }
LaunchedEffect(createRequest) { if (createRequest > 0) vm.createNote(session) }
LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() }
val open = state.openNote
if (open != null) {
NoteEditorScreen(
note = open,
saving = state.saving,
onContentSave = { vm.saveNote(session, it) },
onToggleFavorite = { vm.toggleFavorite(session, open) },
onDelete = { vm.deleteNote(session, open) },
onBack = { vm.closeNote() },
modifier = modifier,
)
ConflictDialog(
conflict = state.conflict,
onKeepMine = { vm.resolveConflictKeepMine(session) },
onUseServer = { vm.resolveConflictUseServer() },
)
return
}
F7ModuleScreen(
title = "Заметки",
modifier = modifier,
loading = state.loading,
error = state.error,
onRefresh = { vm.load(session) },
headerActions = { F7CreateButton(onClick = { vm.createNote(session) }) },
) {
F7OutlinedField(
value = state.searchQuery,
onValueChange = vm::setSearch,
label = "Искать заметки",
modifier = Modifier.fillMaxWidth().padding(bottom = F7Spacing.md),
)
if (state.isEmpty) {
NotesEmptyState(onNewNote = { vm.createNote(session) })
} else {
Column(verticalArrangement = Arrangement.spacedBy(F7Spacing.sm)) {
state.visibleNotes.forEach { note ->
NoteRow(
note = note,
onOpen = { vm.openNote(session, note.id) },
onToggleFavorite = { vm.toggleFavorite(session, note) },
)
}
}
}
}
ConflictDialog(
conflict = state.conflict,
onKeepMine = { vm.resolveConflictKeepMine(session) },
onUseServer = { vm.resolveConflictUseServer() },
)
}
@Composable
private fun NoteRow(note: Note, onOpen: () -> Unit, onToggleFavorite: () -> Unit) {
F7ListCard(onClick = onOpen) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = if (note.favorite) "" else "",
modifier = Modifier.clickable(onClick = onToggleFavorite).padding(end = F7Spacing.sm),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = note.title.ifBlank { "Без названия" },
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (note.category.isNotBlank()) {
Text(text = note.category, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@Composable
private fun NotesEmptyState(onNewNote: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(F7Spacing.lg),
) {
Text(text = "Заметки", fontWeight = FontWeight.Bold)
Spacer(Modifier.height(F7Spacing.sm))
Text(text = "Начните писать заметку, нажав «Новая заметка».")
Spacer(Modifier.height(F7Spacing.md))
F7SolidPrimaryButton(text = "Новая заметка", onClick = onNewNote)
}
}
@Composable
private fun ConflictDialog(conflict: NoteConflictException?, onKeepMine: () -> Unit, onUseServer: () -> Unit) {
if (conflict == null) return
F7AlertDialog(
title = "Конфликт версий",
onDismiss = onUseServer,
confirmText = "Оставить мою версию",
onConfirm = onKeepMine,
dismissText = "Версия сервера",
) {
Text("Заметка была изменена в другой сессии. Выберите, какую версию сохранить.")
}
}
@@ -0,0 +1,165 @@
package ru.forbion.f7cloud.feature.notes
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException
/**
* Состояние экрана Заметок (P1). Список сортируется «избранное вверх», затем по дате
* изменения; поиск фильтрует по заголовку (карта app-map/notes.md).
*/
data class NotesUiState(
val loading: Boolean = false,
val saving: Boolean = false,
val notes: List<Note> = emptyList(),
val searchQuery: String = "",
val openNote: Note? = null,
val conflict: NoteConflictException? = null,
val error: String? = null,
val unauthorized: Boolean = false,
) {
/** Отфильтрованный по поиску и отсортированный список для отображения. */
val visibleNotes: List<Note>
get() = notes
.filter { searchQuery.isBlank() || it.title.contains(searchQuery, ignoreCase = true) }
.sortedWith(compareByDescending<Note> { it.favorite }.thenByDescending { it.modified })
val isEmpty: Boolean get() = !loading && notes.isEmpty()
}
/**
* ViewModel Заметок (P1): список/открытие/сохранение (с разрешением конфликта версий),
* избранное, удаление, создание. Пишет через [NotesRepository]; конфликт `If-Match` (412)
* поднимается в состояние и разрешается ЯВНЫМ выбором пользователя не молчаливо (mail/071).
*/
class NotesViewModel(
private val repository: NotesRepository = NotesRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(NotesUiState())
val state: StateFlow<NotesUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
_state.update { it.copy(loading = true, error = null) }
launchGuarded {
val notes = repository.listNotes(session)
_state.update { it.copy(loading = false, notes = notes) }
}
}
fun setSearch(query: String) = _state.update { it.copy(searchQuery = query) }
fun openNote(session: AuthSession, id: Long) {
launchGuarded {
val note = repository.getNote(session, id)
_state.update { it.copy(openNote = note) }
}
}
fun closeNote() = _state.update { it.copy(openNote = null) }
fun createNote(session: AuthSession) {
_state.update { it.copy(saving = true, error = null) }
launchGuarded {
val note = repository.createNote(session, content = "")
_state.update { it.copy(saving = false, openNote = note, notes = upsert(it.notes, note)) }
}
}
/** Сохранить содержимое открытой заметки; на 412 — поднять конфликт в состояние. */
fun saveNote(session: AuthSession, content: String) {
val current = _state.value.openNote ?: return
_state.update { it.copy(saving = true, error = null) }
viewModelScope.launch {
try {
val saved = repository.saveNote(session, current.copy(content = content))
_state.update {
it.copy(saving = false, openNote = saved, notes = upsert(it.notes, saved))
}
} catch (e: NoteConflictException) {
_state.update { it.copy(saving = false, conflict = e) }
} catch (e: UnauthorizedException) {
_state.update { it.copy(saving = false, unauthorized = true, error = e.message) }
} catch (e: Exception) {
_state.update { it.copy(saving = false, error = e.message) }
}
}
}
/** Разрешение конфликта: перезаписать серверную версию текущим содержимым. */
fun resolveConflictKeepMine(session: AuthSession) {
val conflict = _state.value.conflict ?: return
val mine = _state.value.openNote ?: return
// берём etag серверной версии, чтобы If-Match прошёл, но содержимое — наше
val base = conflict.serverNote?.copy(content = mine.content) ?: mine
_state.update { it.copy(conflict = null, saving = true) }
launchGuarded {
val saved = repository.saveNote(session, base)
_state.update { it.copy(saving = false, openNote = saved, notes = upsert(it.notes, saved)) }
}
}
/** Разрешение конфликта: принять версию сервера (отбросить локальные правки). */
fun resolveConflictUseServer() {
val server = _state.value.conflict?.serverNote
_state.update { st ->
st.copy(
conflict = null,
openNote = server ?: st.openNote,
notes = if (server != null) upsert(st.notes, server) else st.notes,
)
}
}
fun toggleFavorite(session: AuthSession, note: Note) {
launchGuarded {
val updated = repository.setFavorite(session, note.id, !note.favorite)
_state.update { it.copy(notes = upsert(it.notes, updated)) }
}
}
fun deleteNote(session: AuthSession, note: Note) {
launchGuarded {
repository.deleteNote(session, note.id)
_state.update {
it.copy(
notes = it.notes.filterNot { n -> n.id == note.id },
openNote = if (it.openNote?.id == note.id) null else it.openNote,
)
}
}
}
fun clearError() = _state.update { it.copy(error = null) }
private fun launchGuarded(block: suspend () -> Unit) {
viewModelScope.launch {
try {
block()
} catch (e: UnauthorizedException) {
_state.update { it.copy(loading = false, saving = false, unauthorized = true, error = e.message) }
} catch (e: Exception) {
_state.update { it.copy(loading = false, saving = false, error = e.message) }
}
}
}
private fun upsert(notes: List<Note>, note: Note): List<Note> {
val idx = notes.indexOfFirst { it.id == note.id }
return if (idx >= 0) notes.toMutableList().also { it[idx] = note } else notes + note
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
NotesViewModel(NotesRepository(NotesCache(context.applicationContext))) as T
}
}
+1 -2
View File
@@ -5,7 +5,6 @@
<ID>ArgumentListWrapping:TalkComponents.kt$(message.attachmentFileName!!)</ID>
<ID>CyclomaticComplexMethod:TalkFormatting.kt$fun buildChatItems( messages: List&lt;TalkMessage&gt;, lastReadMessageId: Long, selfActorId: String, collapseSystemMessages: Boolean, ): List&lt;TalkChatItem&gt;</ID>
<ID>CyclomaticComplexMethod:TalkScreen.kt$@Composable fun TalkScreen( session: AuthSession, modifier: Modifier = Modifier, openRoomToken: String? = null, scrollToMessageId: Long? = null, chatsListRequest: Int = 0, pushSyncRequest: Int = 0, pushRoomToken: String? = null, onOpenRoomConsumed: () -&gt; Unit = {}, onRoomOpenStateChange: (Boolean) -&gt; Unit = {}, onOpenCalendar: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>CyclomaticComplexMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: () -&gt; Unit, onVoiceToggle: () -&gt; Unit, voiceRecording: Boolean, onRetry: () -&gt; Unit, onStartCall: () -&gt; Unit, onBackClick: () -&gt; Unit, onOpenRoomInfo: () -&gt; Unit, onScheduleMeeting: () -&gt; Unit, onToggleSystemMessages: () -&gt; Unit, onReply: (TalkMessage) -&gt; Unit, onReaction: (TalkMessage, String) -&gt; Unit, onDeleteMessage: (TalkMessage) -&gt; Unit, onEditMessage: (TalkMessage, String) -&gt; Unit, onForwardNoteToSelf: (TalkMessage) -&gt; Unit, onSetReminder: (TalkMessage, ReminderPreset) -&gt; Unit, onMentionQuery: (String?) -&gt; Unit, onMentionSelected: (TalkMention) -&gt; Unit, onClearReply: () -&gt; Unit, onClearHighlight: () -&gt; Unit, )</ID>
<ID>ImportOrdering:TalkComponents.kt$import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import okhttp3.Credentials import ru.forbion.f7cloud.core.auth.AuthSession import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import coil.compose.AsyncImage import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.text.DateFormat import java.util.Calendar import java.util.Date import kotlin.math.abs</ID>
<ID>ImportOrdering:TalkScreen.kt$import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7TextButton import ru.forbion.f7cloud.feature.talknative.TalkNativeCallContext import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher</ID>
<ID>ImportOrdering:TalkViewModel.kt$import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.isActive import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker import ru.forbion.f7cloud.core.network.UnauthorizedException import java.util.Calendar</ID>
@@ -16,7 +15,7 @@
<ID>LongMethod:TalkComponents.kt$@Composable fun TalkRoomRow( room: TalkRoom, session: AuthSession, modifier: Modifier = Modifier, onClick: () -&gt; Unit, onToggleFavorite: () -&gt; Unit = {}, onMarkUnread: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:TalkComponents.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TalkCreateRoomSheet( visible: Boolean, session: AuthSession, mode: CreateRoomMode, roomName: String, userSearchQuery: String, userSearchResults: List&lt;TalkUserCandidate&gt;, selectedUsers: List&lt;TalkUserCandidate&gt;, userSearchLoading: Boolean, creating: Boolean, onDismiss: () -&gt; Unit, onModeChange: (CreateRoomMode) -&gt; Unit, onRoomNameChange: (String) -&gt; Unit, onUserSearchChange: (String) -&gt; Unit, onUserToggle: (TalkUserCandidate) -&gt; Unit, onCreate: () -&gt; Unit, )</ID>
<ID>LongMethod:TalkScreen.kt$@Composable fun TalkScreen( session: AuthSession, modifier: Modifier = Modifier, openRoomToken: String? = null, scrollToMessageId: Long? = null, chatsListRequest: Int = 0, pushSyncRequest: Int = 0, pushRoomToken: String? = null, onOpenRoomConsumed: () -&gt; Unit = {}, onRoomOpenStateChange: (Boolean) -&gt; Unit = {}, onOpenCalendar: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: () -&gt; Unit, onVoiceToggle: () -&gt; Unit, voiceRecording: Boolean, onRetry: () -&gt; Unit, onStartCall: () -&gt; Unit, onBackClick: () -&gt; Unit, onOpenRoomInfo: () -&gt; Unit, onScheduleMeeting: () -&gt; Unit, onToggleSystemMessages: () -&gt; Unit, onReply: (TalkMessage) -&gt; Unit, onReaction: (TalkMessage, String) -&gt; Unit, onDeleteMessage: (TalkMessage) -&gt; Unit, onEditMessage: (TalkMessage, String) -&gt; Unit, onForwardNoteToSelf: (TalkMessage) -&gt; Unit, onSetReminder: (TalkMessage, ReminderPreset) -&gt; Unit, onMentionQuery: (String?) -&gt; Unit, onMentionSelected: (TalkMention) -&gt; Unit, onClearReply: () -&gt; Unit, onClearHighlight: () -&gt; Unit, )</ID>
<ID>LongMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -&gt; Unit, onSend: () -&gt; Unit, onAttach: () -&gt; Unit, onVoiceToggle: () -&gt; Unit, voiceRecording: Boolean, onRetry: () -&gt; Unit, onStartCall: () -&gt; Unit, onBackClick: () -&gt; Unit, onOpenRoomInfo: () -&gt; Unit, onScheduleMeeting: () -&gt; Unit, onToggleSystemMessages: () -&gt; Unit, onReply: (TalkMessage) -&gt; Unit, onReplyPrivately: (TalkMessage) -&gt; Unit, onReaction: (TalkMessage, String) -&gt; Unit, onDeleteMessage: (TalkMessage) -&gt; Unit, onEditMessage: (TalkMessage, String) -&gt; Unit, onForwardNoteToSelf: (TalkMessage) -&gt; Unit, onForwardToRoom: (TalkMessage, String) -&gt; Unit, onSetReminder: (TalkMessage, ReminderPreset) -&gt; Unit, onMentionQuery: (String?) -&gt; Unit, onMentionSelected: (TalkMention) -&gt; Unit, onClearReply: () -&gt; Unit, onClearHighlight: () -&gt; Unit, )</ID>
<ID>LoopWithTooManyJumpStatements:TalkRepository.kt$TalkRepository$for</ID>
<ID>LoopWithTooManyJumpStatements:TalkViewModel.kt$TalkViewModel$while</ID>
<ID>MatchingDeclarationName:TalkFilters.kt$TalkRoomFilter</ID>
@@ -794,19 +794,55 @@ fun TalkUnreadMarker(modifier: Modifier = Modifier) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForwardTargetSheet(
message: TalkMessage?,
rooms: List<TalkRoom>,
onDismiss: () -> Unit,
onPick: (TalkMessage, TalkRoom) -> Unit,
) {
if (message == null) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Переслать в…",
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextSecondary,
)
LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
items(rooms, key = { it.token }) { room ->
F7TextButton(text = room.displayName, onClick = { onPick(message, room) })
}
}
F7TextButton(text = "Отмена", onClick = onDismiss)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TalkMessageActionSheet(
message: TalkMessage?,
canModify: Boolean,
canReplyPrivately: Boolean,
onDismiss: () -> Unit,
onReply: (TalkMessage) -> Unit,
onReplyPrivately: (TalkMessage) -> Unit,
onEdit: (TalkMessage) -> Unit,
onDelete: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
onCopy: (TalkMessage) -> Unit,
onCopyLink: (TalkMessage) -> Unit,
onForwardNoteToSelf: (TalkMessage) -> Unit,
onForward: (TalkMessage) -> Unit,
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
) {
if (message == null) return
@@ -830,6 +866,9 @@ fun TalkMessageActionSheet(
modifier = Modifier.padding(bottom = 8.dp),
)
F7TextButton(text = "Ответить", onClick = { onReply(message) })
if (canReplyPrivately) {
F7TextButton(text = "Ответить лично", onClick = { onReplyPrivately(message) })
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("👍", "❤️", "😂").forEach { emoji ->
F7TextButton(text = emoji, onClick = { onReaction(message, emoji) })
@@ -842,6 +881,7 @@ fun TalkMessageActionSheet(
text = "Переслать в «Личные заметки»",
onClick = { onForwardNoteToSelf(message) },
)
F7TextButton(text = "Переслать…", onClick = { onForward(message) })
Text(
"Напоминание:",
style = MaterialTheme.typography.labelMedium,
@@ -292,10 +292,12 @@ fun TalkScreen(
onScheduleMeeting = onOpenCalendar,
onToggleSystemMessages = vm::toggleSystemMessagesCollapse,
onReply = { vm.setReplyTo(it) },
onReplyPrivately = { vm.replyPrivately(session, it) },
onReaction = { msg, emoji -> vm.addReaction(session, msg.id, emoji) },
onDeleteMessage = { vm.deleteMessage(session, it.id) },
onEditMessage = { msg, text -> vm.editMessage(session, msg.id, text) },
onForwardNoteToSelf = { vm.forwardToNoteToSelf(session, it) },
onForwardToRoom = { msg, token -> vm.forwardToRoom(session, msg, token) },
onSetReminder = { msg, preset -> vm.setMessageReminder(session, msg.id, preset) },
onMentionQuery = { vm.setMentionQuery(session, it) },
onMentionSelected = { mention ->
@@ -415,6 +417,160 @@ private fun RoomListContent(
}
}
@Composable
private fun ConfirmDeleteMessageDialog(
message: TalkMessage?,
onConfirm: (TalkMessage) -> Unit,
onDismiss: () -> Unit,
) {
if (message == null) return
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Удалить сообщение?") },
text = { Text("Сообщение будет удалено для всех участников.") },
confirmButton = {
TextButton(onClick = { onConfirm(message) }) {
Text("Удалить", color = F7Colors.Primary)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Отмена")
}
},
)
}
@Composable
private fun MessageActionOverlays(
actionMessage: TalkMessage?,
session: AuthSession,
roomToken: String?,
rooms: List<TalkRoom>,
onClearAction: () -> Unit,
onReply: (TalkMessage) -> Unit,
onReplyPrivately: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
onForwardNoteToSelf: (TalkMessage) -> Unit,
onForwardToRoom: (TalkMessage, String) -> Unit,
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
onEditMessage: (TalkMessage, String) -> Unit,
onDeleteMessage: (TalkMessage) -> Unit,
) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var editingMessage by remember { mutableStateOf<TalkMessage?>(null) }
var editDraft by remember { mutableStateOf("") }
var pendingDelete by remember { mutableStateOf<TalkMessage?>(null) }
var forwardMessage by remember { mutableStateOf<TalkMessage?>(null) }
TalkMessageActionSheet(
message = actionMessage,
canModify = actionMessage?.let { msg ->
msg.actorId.isNotBlank() && msg.actorId == session.username && !msg.isSystemMessage
} == true,
canReplyPrivately = actionMessage?.let { msg ->
msg.actorId.isNotBlank() && msg.actorId != session.username && !msg.isSystemMessage
} == true,
onDismiss = onClearAction,
onReply = {
onReply(it)
onClearAction()
},
onReplyPrivately = {
onReplyPrivately(it)
onClearAction()
},
onEdit = {
editingMessage = it
editDraft = it.text
onClearAction()
},
onDelete = {
pendingDelete = it
onClearAction()
},
onReaction = { msg, emoji ->
onReaction(msg, emoji)
onClearAction()
},
onCopy = { msg ->
clipboard.setText(AnnotatedString(msg.displayText))
Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show()
onClearAction()
},
onCopyLink = { msg ->
if (roomToken != null) {
clipboard.setText(AnnotatedString(TalkUrls.messageLink(session, roomToken, msg.id)))
Toast.makeText(context, "Ссылка скопирована", Toast.LENGTH_SHORT).show()
}
onClearAction()
},
onForwardNoteToSelf = { msg ->
onForwardNoteToSelf(msg)
Toast.makeText(context, "Переслано в «Личные заметки»", Toast.LENGTH_SHORT).show()
onClearAction()
},
onForward = { msg ->
forwardMessage = msg
onClearAction()
},
onSetReminder = { msg, preset ->
onSetReminder(msg, preset)
Toast.makeText(context, "Напоминание установлено", Toast.LENGTH_SHORT).show()
onClearAction()
},
)
editingMessage?.let { message ->
AlertDialog(
onDismissRequest = { editingMessage = null },
title = { Text("Изменить сообщение") },
text = {
OutlinedTextField(
value = editDraft,
onValueChange = { editDraft = it },
minLines = 2,
maxLines = 6,
)
},
confirmButton = {
TextButton(onClick = {
onEditMessage(message, editDraft)
editingMessage = null
}) {
Text("Сохранить", color = F7Colors.Primary)
}
},
dismissButton = {
TextButton(onClick = { editingMessage = null }) {
Text("Отмена")
}
},
)
}
ConfirmDeleteMessageDialog(
message = pendingDelete,
onConfirm = {
onDeleteMessage(it)
pendingDelete = null
},
onDismiss = { pendingDelete = null },
)
ForwardTargetSheet(
message = forwardMessage,
rooms = rooms.filter { it.token != roomToken },
onDismiss = { forwardMessage = null },
onPick = { msg, room ->
onForwardToRoom(msg, room.token)
forwardMessage = null
Toast.makeText(context, "Переслано", Toast.LENGTH_SHORT).show()
},
)
}
@Composable
private fun ChatContent(
session: AuthSession,
@@ -432,10 +588,12 @@ private fun ChatContent(
onScheduleMeeting: () -> Unit,
onToggleSystemMessages: () -> Unit,
onReply: (TalkMessage) -> Unit,
onReplyPrivately: (TalkMessage) -> Unit,
onReaction: (TalkMessage, String) -> Unit,
onDeleteMessage: (TalkMessage) -> Unit,
onEditMessage: (TalkMessage, String) -> Unit,
onForwardNoteToSelf: (TalkMessage) -> Unit,
onForwardToRoom: (TalkMessage, String) -> Unit,
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
onMentionQuery: (String?) -> Unit,
onMentionSelected: (TalkMention) -> Unit,
@@ -443,13 +601,8 @@ private fun ChatContent(
onClearHighlight: () -> Unit,
) {
var actionMessage by remember { mutableStateOf<TalkMessage?>(null) }
var pendingDelete by remember { mutableStateOf<TalkMessage?>(null) }
var editingMessage by remember { mutableStateOf<TalkMessage?>(null) }
var editDraft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val chatItems = remember(state.messages, state.openRoomLastReadMessageId, state.collapseSystemMessages) {
buildChatItems(
messages = state.messages,
@@ -493,103 +646,22 @@ private fun ChatContent(
}
}
TalkMessageActionSheet(
message = actionMessage,
canModify = actionMessage?.let { msg ->
msg.actorId.isNotBlank() && msg.actorId == session.username && !msg.isSystemMessage
} == true,
onDismiss = { actionMessage = null },
onReply = {
onReply(it)
actionMessage = null
},
onEdit = {
editingMessage = it
editDraft = it.text
actionMessage = null
},
onDelete = {
pendingDelete = it
actionMessage = null
},
onReaction = { msg, emoji ->
onReaction(msg, emoji)
actionMessage = null
},
onCopy = { msg ->
clipboard.setText(AnnotatedString(msg.displayText))
Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show()
actionMessage = null
},
onCopyLink = { msg ->
val token = state.selectedRoomToken
if (token != null) {
clipboard.setText(AnnotatedString(TalkUrls.messageLink(session, token, msg.id)))
Toast.makeText(context, "Ссылка скопирована", Toast.LENGTH_SHORT).show()
}
actionMessage = null
},
onForwardNoteToSelf = { msg ->
onForwardNoteToSelf(msg)
Toast.makeText(context, "Переслано в «Личные заметки»", Toast.LENGTH_SHORT).show()
actionMessage = null
},
onSetReminder = { msg, preset ->
onSetReminder(msg, preset)
Toast.makeText(context, "Напоминание установлено", Toast.LENGTH_SHORT).show()
actionMessage = null
},
MessageActionOverlays(
actionMessage = actionMessage,
session = session,
roomToken = state.selectedRoomToken,
rooms = state.rooms,
onClearAction = { actionMessage = null },
onReply = onReply,
onReplyPrivately = onReplyPrivately,
onReaction = onReaction,
onForwardNoteToSelf = onForwardNoteToSelf,
onForwardToRoom = onForwardToRoom,
onSetReminder = onSetReminder,
onEditMessage = onEditMessage,
onDeleteMessage = onDeleteMessage,
)
editingMessage?.let { message ->
AlertDialog(
onDismissRequest = { editingMessage = null },
title = { Text("Изменить сообщение") },
text = {
OutlinedTextField(
value = editDraft,
onValueChange = { editDraft = it },
minLines = 2,
maxLines = 6,
)
},
confirmButton = {
TextButton(onClick = {
onEditMessage(message, editDraft)
editingMessage = null
}) {
Text("Сохранить", color = F7Colors.Primary)
}
},
dismissButton = {
TextButton(onClick = { editingMessage = null }) {
Text("Отмена")
}
},
)
}
pendingDelete?.let { message ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Удалить сообщение?") },
text = { Text("Сообщение будет удалено для всех участников.") },
confirmButton = {
TextButton(onClick = {
onDeleteMessage(message)
pendingDelete = null
}) {
Text("Удалить", color = F7Colors.Primary)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text("Отмена")
}
},
)
}
Box(modifier = Modifier.fillMaxSize()) {
TalkChatBackground(
session = session,
@@ -625,6 +625,37 @@ class TalkViewModel(
}
}
/** Переслать текст сообщения в выбранную комнату. */
fun forwardToRoom(session: AuthSession, message: TalkMessage, targetToken: String) {
val text = message.displayText.trim()
if (text.isEmpty() || targetToken.isBlank()) return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.sendMessage(session, targetToken, text) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message ?: "Не удалось переслать",
unauthorized = t is UnauthorizedException,
)
}
}
}
/** Ответить лично: открыть (или создать) 1:1 с автором сообщения. */
fun replyPrivately(session: AuthSession, message: TalkMessage) {
val userId = message.actorId
if (userId.isBlank() || userId == session.username) return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.createOneToOneRoom(session, userId) }
.onSuccess { room -> openRoom(session, room.token) }
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message ?: "Не удалось открыть личный чат",
unauthorized = t is UnauthorizedException,
)
}
}
}
/** Напоминание на сообщение по пресету (сегодня/завтра/выходные/неделя). */
fun setMessageReminder(session: AuthSession, messageId: Long, preset: ReminderPreset) {
val token = _state.value.selectedRoomToken ?: return
+9
View File
@@ -8,6 +8,8 @@ coreKtx = "1.15.0" # был дрейф 1.13.1 / 1.15.0
lifecycle = "2.8.7" # был дрейф 2.8.1 / 2.8.7
coil = "2.7.0" # был дрейф 2.6.0 / 2.7.0
media3 = "1.4.1" # нативный медиа-слой (видео/аудио) — ExoPlayer
roborazzi = "1.42.0" # screenshot-тесты Compose на JVM (Fd-сверка без эмулятора)
robolectric = "4.14.1"
coroutines = "1.10.1" # был дрейф 1.8.1 / 1.10.1
navigationCompose = "2.8.9"
room = "2.7.2"
@@ -42,6 +44,13 @@ coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }