Compare commits
124 Commits
92e0e9690b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 05ca2b533a | |||
| 195235495e | |||
| 93086d3f43 | |||
| 002f22bb91 | |||
| 80725396c6 | |||
| bfa870c021 | |||
| 715bb5a056 | |||
| 2854f5d5a4 | |||
| eed7723230 | |||
| 255c962d8f | |||
| a30627a07d | |||
| 5f81bd92f4 | |||
| cc7ad71d41 | |||
| 8a1eba3725 | |||
| 58f11c2991 | |||
| 9efb3595c0 | |||
| fa48569dc1 | |||
| a6bda894b0 | |||
| 1acc604bd5 | |||
| 333026143b | |||
| cc602abc0d | |||
| a8741e982c | |||
| 559a35aeb3 | |||
| eff3f1c843 | |||
| c31adb5692 | |||
| a4a3ff1717 | |||
| d0bf3590d1 | |||
| e6570c008e | |||
| ddd9fd2a33 | |||
| 09b091af01 | |||
| 2567273843 | |||
| 4fdadef16b | |||
| 6b98c5a0ba | |||
| 73762d80c5 | |||
| e3725b7339 | |||
| fb120d5795 | |||
| 7be35490e4 | |||
| 492af6157e | |||
| 2819359b2b | |||
| b818032f68 | |||
| af25ee8e6d | |||
| 9d726f4549 | |||
| 01198a4d14 | |||
| e7d0a5a9ee | |||
| 1c1e9c89e2 | |||
| bc1a4970ef | |||
| f7c0c721a2 | |||
| 48ee65dd9a | |||
| a60fd78a28 | |||
| 8a8f1cfffb | |||
| 6eeba1bba2 | |||
| 22bd16c455 | |||
| 133d80d98a | |||
| e8324254e5 | |||
| c618958fab | |||
| e45b513444 | |||
| bbaaf7ade2 | |||
| 958e14aead | |||
| a665342f49 | |||
| 33f74398c6 | |||
| 59543a7028 | |||
| 056e4dd8ba | |||
| bd56c4bc7b | |||
| cfeb9b3b79 | |||
| 23f7565a1a | |||
| e405dad8fa | |||
| 286722d953 | |||
| b708f44670 | |||
| 81ce1096f7 | |||
| e37833d419 | |||
| 82e260fbf5 | |||
| 9c99cefbb9 | |||
| 5302b80519 | |||
| b3d1633b4f | |||
| 25a185102c | |||
| 92bf902ae2 | |||
| 03460dceaf | |||
| 2cb7a9a470 | |||
| 3c59414345 | |||
| af6acdb54a | |||
| 534dafc2c0 | |||
| c03c096ddf | |||
| ee37c5f5ea | |||
| e627b66429 | |||
| cec5f2c4b9 | |||
| 34a67cc89c | |||
| 6bd0d8fb92 | |||
| 41d5b19285 | |||
| c8905d479a | |||
| 7ec083f609 | |||
| 29560a6865 | |||
| ef07977f9e | |||
| 65a4a49b93 | |||
| ac944c1e5f | |||
| 5213c8b541 | |||
| eac9fefabe | |||
| ece46edecc | |||
| 91aaaba5a0 | |||
| e6a71de7a6 | |||
| 4b000d3b27 | |||
| f70b1861bd | |||
| 6cdef16952 | |||
| c7af5f7d11 | |||
| 2ec2115be7 | |||
| 3e5d0d4892 | |||
| c0703c3537 | |||
| 40e3a4da5c | |||
| a96eda5b7c | |||
| 1e39b2e2fe | |||
| e1e0427066 | |||
| 25cfbe9bdc | |||
| c9340ab2ab | |||
| cdb2471445 | |||
| a5ebcd4d80 | |||
| 374e8838e8 | |||
| 47b2638b2b | |||
| b8e08d1a97 | |||
| fd341cb22a | |||
| b8a40afe3a | |||
| 5daaab732d | |||
| cac214340e | |||
| 0a36abe695 | |||
| 4aa732846d | |||
| 7f95b07666 |
@@ -2,6 +2,22 @@
|
||||
|
||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||
|
||||
- 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. | контракты не менялись | низкий
|
||||
- 2026-07-12 | (без бампа — версия/релиз за лидом) | **Задачи — умные списки, этап 1 (чистая логика + юниты).** Новый движок `TasksSmartLists` (`feature/tasks`): семантика 6 виртуальных списков строго по веб-Tasks Nextcloud — Важное (PRIORITY 1–4), Сегодня (start|due ≤ сегодня), На неделе (≤ сегодня+6), Все (все незавершённые), Текущие (start не в будущем/без start), Завершенные; агрегация по всем реальным спискам; модель видимости `SmartListVisibility` (под будущий SharedPreferences). Добавлено feature-поле `TaskItem.startRaw` + хелпер `startLocalDate()` (пока пусто — DTSTART в `DavTask` ещё нет, запрошен у лида отдельным письмом). Заведена тест-инфра модуля (`testImplementation junit`) + 22 юнит-теста семантики/видимости — зелёные. Detekt-долг чужого пакета v0.5.133–137 (TasksScreen/ViewModel/Components) унесён в `detekt-baseline.xml`, код лида не тронут. UI-подключение — следующий шаг. | контракты не менялись | низкий: изолированная логика, в UI ещё не выведено
|
||||
- 2026-07-10 | v0.5.144 (152) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.133–143). **Whiteboard**: .whiteboard наконец открывается — реальная веб-сессия Nextcloud в WebView (login-flow app-паролем → cookie+CSRF), для web-файлов отключено OkHttp-проксирование (теряло тело POST/PROPFIND). **Календарь — большой пакет**: русские месяцы в шапке; тап по дате не крашит (нативный DatePickerDialog вместо material3); фикс 403 при создании события (учёт supported-calendar-component-set — событие не уходит в VTODO-коллекцию; в ошибках теперь sabre-precondition); окно события 1:1 с живой темой (pill-поля 40dp #FBFBFB/#E6E6E6, шапка с крестиком, зелёная галочка, высота 94% экрана); дата/время нативными пикерами; беседа Talk к событию (выбор существующей или создание публичной/приватной); вложения (ATTACH: «из файлов» с навигацией по WebDAV или загрузка с устройства в /Calendar; round-trip при редактировании); кастомное повторение (модалка: интервал+частота, кружки дней недели, окончание никогда/до даты/N раз ↔ честный RRULE); многодневные события (отдельная дата окончания); несколько напоминаний; управление календарём из сайдбара (✎: переименование, цвет, шаринг пользователям/группам через DAV oc:invite + OCS sharees, удаление с подтверждением; подписки read-only); discard-диалог при закрытии с несохранёнными изменениями; занятость участников (iTIP VFREEBUSY через DAV outbox, конфликты подсвечены). **Задачи** (из 133–137): подзадачи (RELATED-TO), меню «⋯» (вложенная задача/удаление), стрелка панели открывает списки, кэш ускоряет загрузку. **Файлы** (из 133–137): .md в нативном редакторе (WebDAV load/save + предпросмотр), прогрев WebView для docx/xlsx. **Меню**: иконки шторки в белых круглых рамках с тенью, единый размер. Push/звонки: 30-с таймаут → «Пропущенный звонок» (обезличенно на локскрине) | +NextcloudWebSession (POST /login); CalDAV: PROPPATCH/DELETE/share на календарях, POST outbox (VFREEBUSY), ATTACH в VEVENT; OCS: sharees, cloud/user(email), spreed room list | средний: пишущие операции календаря (шаринг/удаление/free-busy/вложения) проверены компиляцией, на устройстве не гонялись
|
||||
|
||||
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
|
||||
|
||||
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
|
||||
|
||||
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
|
||||
|
||||
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
|
||||
|
||||
@@ -39,8 +39,8 @@ android {
|
||||
applicationId 'ru.forbion.f7cloud.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 134
|
||||
versionName '0.5.126'
|
||||
versionCode 152
|
||||
versionName '0.5.144'
|
||||
missingDimensionStrategy 'default', 'f7'
|
||||
multiDexEnabled true
|
||||
|
||||
@@ -107,6 +107,7 @@ dependencies {
|
||||
implementation project(':feature:calendar')
|
||||
implementation project(':feature:contacts')
|
||||
implementation project(':feature:tasks')
|
||||
implementation project(':feature:notes')
|
||||
implementation project(':feature:deck')
|
||||
implementation project(':feature:mail')
|
||||
implementation project(':feature:f7support')
|
||||
|
||||
@@ -77,7 +77,31 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
// OkHttp с Basic-auth для хоста сервера: серверные картинки (иконки внешних сайтов
|
||||
// /apps/external/img, превью и т.п.) требуют логина — без заголовка coil ловит 401.
|
||||
val authedClient = okhttp3.OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val session = runCatching { AuthStore(this).load() }.getOrNull()
|
||||
val serverHost = session?.serverUrl?.let {
|
||||
runCatching { android.net.Uri.parse(it).host }.getOrNull()
|
||||
}
|
||||
val patched = if (session != null && serverHost != null && request.url.host == serverHost) {
|
||||
request.newBuilder()
|
||||
.header(
|
||||
"Authorization",
|
||||
okhttp3.Credentials.basic(session.username, session.appPassword),
|
||||
)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.build()
|
||||
} else {
|
||||
request
|
||||
}
|
||||
chain.proceed(patched)
|
||||
}
|
||||
.build()
|
||||
return ImageLoader.Builder(this)
|
||||
.okHttpClient(authedClient)
|
||||
.components { add(SvgDecoder.Factory()) }
|
||||
.crossfade(false)
|
||||
.memoryCache {
|
||||
|
||||
@@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.NextcloudWebSession
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
|
||||
class OfficeEditorActivity : ComponentActivity() {
|
||||
@@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() {
|
||||
if (current == next) return
|
||||
editorVisible = false
|
||||
loadError = null
|
||||
view.loadUrl(target.url)
|
||||
startLoad(view, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collabora — грузим сразу (iframe самодостаточен по WOPI-токену). Web-viewer (доска и пр.)
|
||||
* требует НАСТОЯЩУЮ веб-сессию: сначала логинимся app-паролем, переносим cookie в
|
||||
* WebView CookieManager, только потом открываем URL — иначе viewer видит анонима («нет файлов»).
|
||||
*/
|
||||
private fun startLoad(view: WebView, target: OfficeEditorLaunch) {
|
||||
if (target.collaboraBaseUrl.isNotBlank()) {
|
||||
view.loadUrl(target.url)
|
||||
return
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
val ok = runCatching { establishWebSession(target) }.getOrDefault(false)
|
||||
if (!ok) {
|
||||
loadError = "Не удалось открыть сессию. Проверьте интернет и попробуйте снова."
|
||||
return@launch
|
||||
}
|
||||
view.loadUrl(target.url)
|
||||
}
|
||||
}
|
||||
|
||||
/** Логин по форме `/login`, перенос session-cookie в WebView. Возвращает true при успехе. */
|
||||
private suspend fun establishWebSession(target: OfficeEditorLaunch): Boolean {
|
||||
val serverUrl = target.serverUrl.ifBlank { target.url }
|
||||
val cookies = withContext(Dispatchers.IO) {
|
||||
NextcloudWebSession.login(
|
||||
serverUrl = serverUrl,
|
||||
username = target.username,
|
||||
appPassword = target.password,
|
||||
trustAllCerts = target.trustAllCerts,
|
||||
)
|
||||
}
|
||||
if (cookies.isEmpty()) return false
|
||||
val cm = CookieManager.getInstance()
|
||||
cm.setAcceptCookie(true)
|
||||
webViewRef?.let { cm.setAcceptThirdPartyCookies(it, true) }
|
||||
val base = serverUrl.trimEnd('/')
|
||||
cookies.forEach { c ->
|
||||
val attrs = buildString {
|
||||
append("${c.name}=${c.value}; Path=${c.path}")
|
||||
if (c.secure) append("; Secure")
|
||||
if (c.httpOnly) append("; HttpOnly")
|
||||
}
|
||||
cm.setCookie(base, attrs)
|
||||
}
|
||||
withContext(Dispatchers.IO) { cm.flush() }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun readLaunch(): OfficeEditorLaunch? {
|
||||
@@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() {
|
||||
val current = webView.url?.trimEnd('/').orEmpty()
|
||||
val target = launch.url.trimEnd('/')
|
||||
if (current != target) {
|
||||
webView.loadUrl(launch.url)
|
||||
startLoad(webView, launch)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -346,7 +399,14 @@ class OfficeEditorActivity : ComponentActivity() {
|
||||
},
|
||||
onPageReady = { view ->
|
||||
runOnUiThread {
|
||||
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||
} else {
|
||||
// Web-файлы (whiteboard и пр.): Collabora-моста нет — готовность = загрузка
|
||||
// страницы; chrome НЕ прячем (иначе снесётся весь контейнер приложения).
|
||||
editorVisible = true
|
||||
loadError = null
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -23,12 +23,17 @@ internal class OfficeWebViewClient(
|
||||
private val onPageReady: (WebView?) -> Unit,
|
||||
) : WebViewClient() {
|
||||
|
||||
// Скрытие chrome forbion — ТОЛЬКО для Collabora-страницы (там документ в отдельном
|
||||
// #documents-content). На Files-странице (whiteboard и пр.) этот скрипт сносит #body-user
|
||||
// = весь контейнер приложения → белый экран. Поэтому для web-файлов не инжектим.
|
||||
private val hideChrome: Boolean get() = launch.collaboraBaseUrl.isNotBlank()
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
onPageReady(view)
|
||||
}
|
||||
|
||||
@@ -123,11 +128,10 @@ internal class OfficeWebViewClient(
|
||||
}
|
||||
|
||||
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
||||
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
|
||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||
prefixes += launch.collaboraBaseUrl.trimEnd('/')
|
||||
}
|
||||
return prefixes.distinct()
|
||||
// Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии
|
||||
// (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files.
|
||||
if (launch.collaboraBaseUrl.isBlank()) return emptyList()
|
||||
return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ object AppMenuRepository {
|
||||
}
|
||||
AppMenuExternalSite(
|
||||
name = name,
|
||||
iconUrl = icon ?: defaultExternalIcon(session.serverUrl, name),
|
||||
iconUrl = resolveExternalIcon(session.serverUrl, icon, name),
|
||||
openUrl = openUrl,
|
||||
)
|
||||
}
|
||||
@@ -56,12 +56,18 @@ object AppMenuRepository {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun defaultExternalIcon(serverUrl: String, name: String): String {
|
||||
/**
|
||||
* Иконка внешнего сайта. API external отдаёт `icon` как ИМЯ ФАЙЛА (напр. «bitrix24.png»),
|
||||
* а не URL — сайт грузит его с `/apps/external/img/{icon}` (см. layout.user.php). Раньше
|
||||
* имя файла бралось как есть → битая ссылка → глобус-заглушка у Bitrix/1C.
|
||||
*/
|
||||
private fun resolveExternalIcon(serverUrl: String, icon: String?, name: String): String {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
return when {
|
||||
name.contains("bitrix", ignoreCase = true) -> "$base/themes/forbion/images/header/bitrix-glass.svg"
|
||||
name.contains("1c", ignoreCase = true) -> "$base/themes/forbion/images/header/1c-glass.svg"
|
||||
else -> "$base/index.php/apps/external/img/external.svg"
|
||||
icon.isNullOrBlank() -> "$base/index.php/apps/external/img/external.svg"
|
||||
icon.startsWith("http") -> icon
|
||||
icon.startsWith("/") -> "$base$icon"
|
||||
else -> "$base/index.php/apps/external/img/$icon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
||||
|
||||
// Иконки шторки — ПЛОСКИЕ menu-иконки живой темы forbion (images/menu/*.svg: зелёный+чёрный
|
||||
// контур), а не glass-кружки. Локальные (assets/menu/menu-*.svg) — офлайн и 1:1 с сайтом.
|
||||
private fun menuIcon(name: String): String = "file:///android_asset/menu/menu-$name.svg"
|
||||
|
||||
enum class AppTab(
|
||||
val title: String,
|
||||
val headerIconPath: String,
|
||||
@@ -17,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"),
|
||||
;
|
||||
|
||||
@@ -34,12 +36,16 @@ private data class AppMenuEntry(
|
||||
|
||||
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
|
||||
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
|
||||
// iconPath — базовое имя плоской menu-иконки (assets/menu/menu-<name>.svg).
|
||||
// Состав/порядок 1:1 со шторкой сайта: Файлы·Календарь·Контакты·Карточки·Заметки·Поддержка
|
||||
// (Почта/Задачи/Конференции не тут — они в нижней панели).
|
||||
private val coreAppMenuEntries = listOf(
|
||||
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
|
||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
|
||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
|
||||
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
|
||||
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
|
||||
AppMenuEntry(AppTab.Files, "Файлы", "files"),
|
||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
|
||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
|
||||
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
|
||||
AppMenuEntry(AppTab.Notes, "Заметки", "notes"),
|
||||
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
|
||||
)
|
||||
|
||||
data class AppMenuExternalSite(
|
||||
@@ -59,7 +65,7 @@ fun appMenuItems(
|
||||
val core = coreAppMenuEntries.map { entry ->
|
||||
F7AppMenuItem(
|
||||
label = entry.label,
|
||||
iconUrl = "$base/themes/forbion/images/header/${entry.iconPath}",
|
||||
iconUrl = menuIcon(entry.iconPath),
|
||||
selected = entry.tab == active,
|
||||
externalUrl = entry.webPath?.let { "$base$it" },
|
||||
)
|
||||
@@ -72,19 +78,23 @@ fun appMenuItems(
|
||||
externalUrl = site.openUrl,
|
||||
)
|
||||
}
|
||||
// Нативные пункты по дизайну — действия обрабатываются в AppScaffold по label
|
||||
// Нативные пункты — плоские menu-иконки темы, действия по label в AppScaffold.
|
||||
// Порядок как на сайте: после внешних сайтов — Личный кабинет, затем Уведомления, Настройки.
|
||||
val native = listOf(
|
||||
F7AppMenuItem(
|
||||
label = "Уведомления",
|
||||
iconUrl = "",
|
||||
label = "Личный кабинет",
|
||||
iconUrl = menuIcon("lk"),
|
||||
selected = false,
|
||||
),
|
||||
F7AppMenuItem(
|
||||
label = "Уведомления",
|
||||
iconUrl = menuIcon("notifications"),
|
||||
selected = false,
|
||||
localIcon = Icons.Filled.Notifications,
|
||||
),
|
||||
F7AppMenuItem(
|
||||
label = "Настройки",
|
||||
iconUrl = "",
|
||||
iconUrl = menuIcon("settings"),
|
||||
selected = false,
|
||||
localIcon = Icons.Filled.Settings,
|
||||
),
|
||||
)
|
||||
return core + external + native
|
||||
|
||||
@@ -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
|
||||
@@ -134,6 +135,7 @@ fun AppScaffold(
|
||||
var filesUploadRequest by remember { mutableIntStateOf(0) }
|
||||
var contactsCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var tasksCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var tasksShowListsRequest by remember { mutableIntStateOf(0) }
|
||||
var supportCreateRequest by remember { mutableIntStateOf(0) }
|
||||
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
|
||||
var talkChatsRequest by remember { mutableIntStateOf(0) }
|
||||
@@ -458,7 +460,12 @@ fun AppScaffold(
|
||||
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
||||
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
||||
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
||||
else -> Unit
|
||||
// Задачи: стрелка открывает панель списков (закрывает
|
||||
// открытый список) — как сайдбар в Почте/Файлах.
|
||||
AppTab.Tasks -> tasksShowListsRequest++
|
||||
// Разделы без сайдбара (Карточки/Конференции/Контакты/
|
||||
// Поддержка): стрелка = «назад» к предыдущему разделу.
|
||||
else -> popTabHistory()?.let { activeTab = it }
|
||||
}
|
||||
},
|
||||
onMailClick = {
|
||||
@@ -467,10 +474,10 @@ fun AppScaffold(
|
||||
activeTab = AppTab.Mail
|
||||
}
|
||||
},
|
||||
onCardsClick = {
|
||||
if (activeTab != AppTab.Deck) {
|
||||
onTasksClick = {
|
||||
if (activeTab != AppTab.Tasks) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = AppTab.Deck
|
||||
activeTab = AppTab.Tasks
|
||||
}
|
||||
},
|
||||
onConferencesClick = {
|
||||
@@ -564,9 +571,15 @@ fun AppScaffold(
|
||||
modifier = contentModifier,
|
||||
createRequest = tasksCreateRequest,
|
||||
openListSlug = pendingTasksListSlug,
|
||||
showListsRequest = tasksShowListsRequest,
|
||||
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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1460_27244)">
|
||||
<path d="M3 3H13.5341" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 8H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 13H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1460_27244">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 570 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
|
||||
<path d="M1 26.2893V9H29V26.2893C29 27.7864 27.7864 29 26.2893 29H3.71067C2.21361 29 1 27.7864 1 26.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||
<path d="M1 9V5.71067C1 4.21361 2.21361 3 3.71067 3H26.2893C27.7864 3 29 4.21361 29 5.71067V9H1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||
<path d="M6.32233 1H5.67767C5.3034 1 5 1.3034 5 1.67767V5.32233C5 5.6966 5.3034 6 5.67767 6H6.32233C6.6966 6 7 5.6966 7 5.32233V1.67767C7 1.3034 6.6966 1 6.32233 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||
<path d="M12.3223 1H11.6777C11.3034 1 11 1.3034 11 1.67767V5.32233C11 5.6966 11.3034 6 11.6777 6H12.3223C12.6966 6 13 5.6966 13 5.32233V1.67767C13 1.3034 12.6966 1 12.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||
<path d="M18.3223 1H17.6777C17.3034 1 17 1.3034 17 1.67767V5.32233C17 5.6966 17.3034 6 17.6777 6H18.3223C18.6966 6 19 5.6966 19 5.32233V1.67767C19 1.3034 18.6966 1 18.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||
<path d="M24.3223 1H23.6777C23.3034 1 23 1.3034 23 1.67767V5.32233C23 5.6966 23.3034 6 23.6777 6H24.3223C24.6966 6 25 5.6966 25 5.32233V1.67767C25 1.3034 24.6966 1 24.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||
<path
|
||||
d="M14.5436 21.844V23H10.1415V21.844H11.7412V15.9356C11.679 16.029 11.5739 16.1341 11.426 16.2509C11.2858 16.3598 11.1263 16.4688 10.9472 16.5778C10.7682 16.679 10.5813 16.7646 10.3867 16.8347C10.1999 16.9048 10.0287 16.9398 9.87296 16.9398V15.7488C10.0831 15.7488 10.2933 15.6943 10.5035 15.5853C10.7215 15.4763 10.9239 15.3517 11.1107 15.2116C11.2975 15.0637 11.4454 14.9314 11.5544 14.8146C11.6712 14.6979 11.7335 14.6356 11.7412 14.6278H13.0607V21.844H14.5436ZM18.0332 23.0934C17.5739 23.0934 17.1535 23.0272 16.7721 22.8949C16.3984 22.7626 16.0832 22.5719 15.8263 22.3228C15.5694 22.0736 15.3825 21.7856 15.2658 21.4587L15.9664 20.6179C16.0209 20.8203 16.1338 21.0227 16.305 21.2251C16.4841 21.4275 16.7137 21.591 16.9939 21.7156C17.282 21.8401 17.6167 21.9024 17.9981 21.9024C18.3329 21.9024 18.6287 21.8518 18.8856 21.7506C19.1502 21.6416 19.3604 21.4898 19.5161 21.2952C19.6718 21.1006 19.7496 20.8709 19.7496 20.6063C19.7496 20.3105 19.6562 20.0614 19.4694 19.859C19.2826 19.6566 19.014 19.5048 18.6637 19.4036C18.3134 19.3024 17.893 19.2518 17.4026 19.2518H17.0056V18.2242H17.4026C18.0487 18.2242 18.5625 18.1191 18.9439 17.909C19.3332 17.6988 19.5278 17.3874 19.5278 16.9748C19.5278 16.7179 19.4577 16.4961 19.3176 16.3092C19.1775 16.1146 18.9945 15.9706 18.7688 15.8772C18.543 15.776 18.2939 15.7254 18.0215 15.7254C17.5778 15.7254 17.1924 15.8266 16.8655 16.029C16.5385 16.2314 16.3011 16.4805 16.1532 16.7763L15.3125 15.8538C15.476 15.597 15.7017 15.3751 15.9897 15.1883C16.2778 15.0014 16.6008 14.8574 16.9589 14.7562C17.3248 14.655 17.6945 14.6044 18.0682 14.6044C18.6131 14.6044 19.0996 14.7017 19.5278 14.8964C19.9559 15.091 20.2907 15.3595 20.532 15.7021C20.7733 16.0368 20.894 16.4182 20.894 16.8464C20.894 17.1577 20.8278 17.438 20.6955 17.6871C20.5709 17.9362 20.388 18.1464 20.1466 18.3176C19.9131 18.4811 19.6329 18.6018 19.3059 18.6796C19.6562 18.7419 19.9637 18.8742 20.2284 19.0766C20.5008 19.279 20.7071 19.5281 20.8473 19.8239C20.9952 20.1197 21.0691 20.4428 21.0691 20.7931C21.0691 21.2757 20.929 21.6883 20.6487 22.0308C20.3763 22.3733 20.0104 22.638 19.5511 22.8248C19.0919 23.0039 18.5859 23.0934 18.0332 23.0934Z"
|
||||
fill="#70B62B"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="27" viewBox="0 0 28 27"
|
||||
fill="none">
|
||||
<g clip-path="url(#clip0_4701_11222)">
|
||||
<rect width="28" height="27" fill="white"/>
|
||||
<path d="M1 23.2893V3.71C1 2.21331 2.21331 1 3.71 1H24.29C25.7867 1 27 2.21331 27 3.71V23.2893C27 24.7864 25.7864 26 24.2893 26H3.71067C2.21361 26 1 24.7864 1 23.2893Z"
|
||||
fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
|
||||
<path d="M3.5 6L8.5 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
|
||||
<path d="M19.5 6L24.5756 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
|
||||
<path d="M11.5 6L16.681 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
|
||||
<rect x="3.45" y="20.55" width="12.1" height="5.1" rx="0.55" transform="rotate(-90 3.45 20.55)" stroke="black"
|
||||
stroke-width="0.9"/>
|
||||
<rect x="19.45" y="20.55" width="12.1" height="5.1" rx="0.55" transform="rotate(-90 19.45 20.55)" stroke="black"
|
||||
stroke-width="0.9"/>
|
||||
<rect x="11.45" y="13.55" width="5.1" height="5.1" rx="0.55" transform="rotate(-90 11.45 13.55)" stroke="black"
|
||||
stroke-width="0.9"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4701_11222">
|
||||
<rect width="28" height="27" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="30" viewBox="0 0 29 30" fill="none">
|
||||
<path
|
||||
d="M28 14.5063V3.86241C28 3.14045 27.1472 2.75719 26.6073 3.23654L19.5114 9.53691C19.0911 9.91008 19.1508 10.5832 19.6301 10.8766L26.7261 15.2202C27.2838 15.5616 28 15.1602 28 14.5063Z"
|
||||
fill="#F5F5F5"
|
||||
stroke="black"
|
||||
stroke-width="0.836976"
|
||||
/>
|
||||
<path d="M0 2.51093C0 1.12418 1.12418 0 2.51093 0H18.4891C19.8758 0 21 1.12418 21 2.51093V11V15.4891C21 16.8758 19.8758 18 18.4891 18H2.51093C1.12418 18 0 16.8758 0 15.4891V2.51093Z" fill="#F5F5F5" />
|
||||
<path
|
||||
d="M0.418488 2.51093C0.418488 1.35531 1.3553 0.418488 2.51093 0.418488H18.4891C19.6447 0.418488 20.5815 1.3553 20.5815 2.51093V11V15.4891C20.5815 16.6447 19.6447 17.5815 18.4891 17.5815H2.51093C1.35531 17.5815 0.418488 16.6447 0.418488 15.4891V2.51093Z"
|
||||
stroke="black"
|
||||
stroke-width="0.836976"
|
||||
/>
|
||||
<line x1="2.33337" y1="12.536" x2="17.8889" y2="12.536" stroke="#70B62B" stroke-width="1.67395" />
|
||||
<rect x="17.1111" y="3.08606" width="1.55556" height="2.05738" fill="#70B62B" />
|
||||
<path d="M0.941024 25.501L7.49997 17.6557L10.3173 17.7274L2.57507 27.2897C2.23834 27.7056 1.68223 27.7818 1.23338 27.4736C0.606407 27.043 0.462663 26.0731 0.941024 25.501Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" />
|
||||
<path d="M18.5966 27.3311L10.6298 17.6627L13.6298 17.6627L20.4548 26.2201C20.7632 26.6068 20.7068 27.1272 20.3206 27.4581C19.8242 27.8833 19.0017 27.8227 18.5966 27.3311Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="29" viewBox="0 0 26 29" fill="none">
|
||||
<path d="M4 25.2893V3.71C4 2.21331 5.21331 1 6.71 1H22.29C23.7867 1 25 2.21331 25 3.71V25.2893C25 26.7864 23.7864 28 22.2893 28H6.71067C5.21361 28 4 26.7864 4 25.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||
<path d="M4 26V3C4 1.89543 4.89543 1 6 1H8V28H6C4.89543 28 4 27.1046 4 26Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||
<path d="M1.16211 13.6755V14.3201C1.16211 14.6944 1.46551 14.9978 1.83978 14.9978H5.48444C5.85871 14.9978 6.16211 14.6944 6.16211 14.3201V13.6755C6.16211 13.3012 5.85871 12.9978 5.48444 12.9978H1.83978C1.46551 12.9978 1.16211 13.3012 1.16211 13.6755Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||
<path d="M1.16211 19.6777V20.3223C1.16211 20.6966 1.46551 21 1.83978 21H5.48444C5.85871 21 6.16211 20.6966 6.16211 20.3223V19.6777C6.16211 19.3034 5.85871 19 5.48444 19H1.83978C1.46551 19 1.16211 19.3034 1.16211 19.6777Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||
<path d="M1 7.67767V8.32233C1 8.6966 1.3034 9 1.67767 9H5.32233C5.6966 9 6 8.6966 6 8.32233V7.67767C6 7.3034 5.6966 7 5.32233 7L1.67767 7C1.3034 7 1 7.3034 1 7.67767Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||
<circle cx="16.5" cy="12.4375" r="2.0625" fill="#70B62B"></circle>
|
||||
<path d="M16.5 15.1875C14.1966 15.1875 12.8578 16.9963 12.4838 18.0888C12.3854 18.3761 12.6212 18.625 12.925 18.625H20.075C20.3788 18.625 20.6146 18.3761 20.5162 18.0888C20.1422 16.9963 18.8034 15.1875 16.5 15.1875Z" fill="#70B62B"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
|
||||
<path
|
||||
d="M28 12.6762V19.3238C28 20.8018 26.8018 22 25.3237 22H3.67625C2.1982 22 1 20.8018 1 19.3238V9.67625C1 8.1982 2.1982 7 3.67625 7H14.8915C15.6012 7 16.282 7.28196 16.7839 7.78386L18.2161 9.21614C18.718 9.71804 19.3988 10 20.1085 10H25.3238C26.8018 10 28 11.1982 28 12.6762Z"
|
||||
fill="#F5F5F5"
|
||||
stroke="black"
|
||||
stroke-width="0.892083"
|
||||
/>
|
||||
<path d="M5 1V22H25V1H5Z" fill="#F5F5F5" stroke="black" stroke-width="0.892083" />
|
||||
<path d="M4 3V25H23V3H4Z" fill="#FCFEFC" stroke="black" stroke-width="0.892083" />
|
||||
<path
|
||||
d="M1 17.6762V26.3238C1 27.8018 2.1982 29 3.67625 29H26.3237C27.8018 29 29 27.8018 29 26.3238V14.6762C29 13.1982 27.8018 12 26.3238 12H12.7791C12.0774 12 11.4038 12.2756 10.9033 12.7675L9.41252 14.2325C8.91203 14.7244 8.23837 15 7.53665 15H3.67625C2.1982 15 1 16.1982 1 17.6762Z"
|
||||
fill="#F5F5F5"
|
||||
stroke="black"
|
||||
stroke-width="0.892083"
|
||||
/>
|
||||
<path d="M6 6H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
|
||||
<path d="M6 8H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
|
||||
<path d="M6 10H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_205_498)">
|
||||
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#e5efe8"/>
|
||||
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
|
||||
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
|
||||
<path d="M15.7661 32.1765L16.9463 25.8847C16.9619 25.8019 17.0341 25.7419 17.1183 25.7419H17.7667C17.8518 25.7419 17.9246 25.8032 17.9391 25.8871L19.0289 32.1808C19.0379 32.2323 19.0233 32.285 18.9893 32.3247L17.5726 33.9747C17.504 34.0545 17.3811 34.0563 17.3103 33.9784L15.8086 32.3265C15.7717 32.2859 15.7559 32.2304 15.7661 32.1765Z" fill="#70B62B"/>
|
||||
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
|
||||
<rect x="22.451" y="12.5238" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5238)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_205_498">
|
||||
<rect width="35" height="35" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="31" height="25" viewBox="0 0 31 25" fill="none">
|
||||
<path d="M1 3.33195V22.2722C1 22.8812 1.36501 23.4309 1.92631 23.6671C2.11218 23.7454 2.31181 23.7857 2.51347 23.7857H14.2872H21.3335H28.1001C28.2854 23.7857 28.4696 23.7573 28.6463 23.7015L28.7223 23.6775C29.4771 23.4392 29.9903 22.7391 29.9903 21.9476V3.33195C29.9903 2.33004 29.1781 1.51782 28.1761 1.51782H15.4951H2.81413C1.81221 1.51782 1 2.33004 1 3.33195Z" fill="#F5F5F5" stroke="black" stroke-width="0.907066"/>
|
||||
<path d="M29.5785 20.5535L17.4516 8.17127C16.3816 7.07877 14.6218 7.08216 13.5561 8.17876L1.75937 20.3169C1.27243 20.8179 1.00003 21.4891 1.00003 22.1877V22.6527C1.00003 23.0415 1.1994 23.4031 1.52818 23.6107C1.70922 23.725 1.91896 23.7857 2.13308 23.7857H3.41589L8.16288 23.7857L21.7535 23.7857L28.3797 23.7857C30.0271 23.7857 30.4021 21.643 29.5785 20.5535Z" stroke="black" stroke-width="0.907066"/>
|
||||
<path d="M1.63846 4.39857L14.8989 16.0094C15.2425 16.3102 15.7562 16.3087 16.098 16.0058L29.54 4.09441C29.8325 3.83517 30 3.46302 30 3.07214C30 2.7761 29.9038 2.48808 29.7259 2.25144L29.582 2.06001C29.33 1.72469 28.9826 1.47328 28.5853 1.33869L28.0101 1.14386C27.7289 1.0486 27.434 1 27.1371 1H22.841H16.0351H12.4121H8.78904H2.57812C1.03955 1 0.661144 2.87867 1.28989 3.99863C1.37708 4.15393 1.50446 4.28124 1.63846 4.39857Z" fill="#F5F5F5" stroke="black" stroke-width="0.907066"/>
|
||||
<path d="M15.0422 6.5331C14.7955 6.5331 14.5681 6.62806 14.3593 6.81946C14.1506 7.01087 13.9925 7.28302 13.8867 7.63592C13.7815 7.98882 13.7279 8.3021 13.7279 8.575C13.7279 8.93912 13.8017 9.20828 13.9485 9.38024C14.0962 9.55221 14.2788 9.63894 14.4965 9.63894L14.668 10.3791C14.5397 10.4113 14.407 10.427 14.2691 10.427C13.7935 10.427 13.3991 10.2625 13.0852 9.93352C12.9876 9.83109 12.9048 9.71744 12.8377 9.59333C12.6894 9.32043 12.6148 8.99669 12.6148 8.62136C12.6148 7.94621 12.8042 7.33012 13.1821 6.77385C13.6406 6.09571 14.2274 5.75702 14.9438 5.75702C14.999 5.75702 15.0527 5.75926 15.1041 5.7645L15.0422 6.53236V6.5331Z" fill="#70B62B"/>
|
||||
<path d="M17.5203 5.50581C17.0104 5.03029 16.2984 4.79253 15.3852 4.79253C14.6091 4.79253 13.9515 4.95179 13.4118 5.2688C12.872 5.58581 12.4649 6.03218 12.1913 6.60863C11.9177 7.18434 11.7813 7.78397 11.7813 8.40679C11.7813 8.89876 11.882 9.35558 12.0832 9.77652C12.1295 9.87372 12.1817 9.96868 12.2383 10.0621C12.5432 10.5593 12.9719 10.9212 13.5228 11.1493C14.0738 11.3773 14.7045 11.4917 15.415 11.4917C16.1255 11.4917 16.6883 11.396 17.1804 11.2053C17.6732 11.0147 18.0698 10.7276 18.371 10.3463H19.2857C18.9994 10.9294 18.5603 11.3855 17.9669 11.7167C17.2877 12.0958 16.4602 12.2857 15.4836 12.2857C14.5069 12.2857 13.7219 12.1257 13.0353 11.8057C12.3479 11.485 11.8372 11.0132 11.5017 10.3881C11.167 9.76232 11 9.08193 11 8.34697C11 7.53948 11.1901 6.78807 11.5711 6.09124C11.952 5.39516 12.4739 4.87179 13.1352 4.52262C13.7964 4.17496 14.5524 4 15.403 4C16.1247 4 16.7659 4.14131 17.328 4.42244C17.8894 4.70356 18.318 5.10431 18.614 5.62245C18.9107 6.14059 19.0591 6.70732 19.0591 7.32191C19.0591 8.05388 18.8339 8.71632 18.3844 9.30773C17.82 10.0547 17.0969 10.4277 16.2149 10.4277C15.9778 10.4277 15.7982 10.3866 15.6774 10.3029C15.5574 10.2199 15.4769 10.098 15.4373 9.93727C15.2047 10.1623 14.9475 10.3096 14.6672 10.3799L14.4957 9.6397C14.659 9.6397 14.8118 9.59932 14.955 9.51858C15.0638 9.46175 15.1712 9.37128 15.2771 9.24717C15.4291 9.07222 15.5596 8.81651 15.6707 8.48006C15.781 8.14435 15.8362 7.83183 15.8362 7.54098C15.8362 7.21724 15.7609 6.96751 15.611 6.79405C15.4604 6.61985 15.2711 6.53312 15.0422 6.53312L15.1041 5.76526C15.5328 5.80264 15.8563 5.99554 16.0733 6.34396L16.1806 5.86544H17.3146L16.666 8.95408C16.6264 9.14997 16.6056 9.27633 16.6056 9.33315C16.6056 9.40493 16.622 9.45876 16.6555 9.49465C16.6891 9.53054 16.7278 9.54848 16.7733 9.54848C16.9105 9.54848 17.0887 9.46549 17.3056 9.29876C17.5979 9.08044 17.8342 8.7881 18.0146 8.42024C18.195 8.05239 18.2852 7.67182 18.2852 7.27855C18.2852 6.61611 18.0608 6.0561 17.6135 5.59628C17.5837 5.56563 17.5524 5.53572 17.5203 5.50581Z" fill="#70B62B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26" fill="none">
|
||||
<g clip-path="url(#clip0_2948_1176)">
|
||||
<path d="M0 23.2893L0 2.71C0 1.21331 1.21331 0 2.71 0L23.29 0C24.7867 0 26 1.21331 26 2.71V23.2893C26 24.7864 24.7864 26 23.2893 26H2.71067C1.21361 26 0 24.7864 0 23.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
|
||||
<path d="M20.3343 9.82838L22.2668 11.6587M15 17L17.4497 16.8716L22.5941 11.4795C22.7228 11.3447 22.8249 11.1845 22.8945 11.0083C22.9642 10.8321 23 10.6432 23 10.4525C23 10.2617 22.9642 10.0729 22.8945 9.89664C22.8249 9.72042 22.7228 9.5603 22.5941 9.42542C22.4654 9.29055 22.3127 9.18356 22.1445 9.11056C21.9764 9.03757 21.7962 9 21.6142 9C21.4323 9 21.2521 9.03757 21.0839 9.11056C20.9158 9.18356 20.763 9.29055 20.6343 9.42542L15.4899 14.8175L15 17Z" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 12H15" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
|
||||
<path d="M3 16H13" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
|
||||
<path d="M3 20H15" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
|
||||
<path d="M0 6L0 2.71067C0 1.21361 1.21361 0 2.71067 0L23.2893 0C24.7864 0 26 1.21361 26 2.71067V6H0Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2948_1176">
|
||||
<rect width="26" height="26" rx="2" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.4414 21.2559C14.3847 22.1215 14.1349 22.8742 13.7871 23.4141C13.3852 24.0377 12.9135 24.2998 12.499 24.2998C12.0847 24.2996 11.6136 24.0375 11.2119 23.4141C10.8642 22.8742 10.6133 22.1215 10.5566 21.2559C11.1641 21.2729 11.8114 21.2861 12.5 21.2861C13.1882 21.2861 13.8344 21.2729 14.4414 21.2559Z" fill="#F5F5F5" stroke="#808080" stroke-width="1.4"/>
|
||||
<path d="M12.4297 0.700195C13.0843 0.700204 13.6259 1.23742 13.626 1.91406C13.626 2.59074 13.0844 3.12792 12.4297 3.12793C11.775 3.12793 11.2334 2.59075 11.2334 1.91406C11.2334 1.23741 11.775 0.700195 12.4297 0.700195Z" fill="white" stroke="#808080" stroke-width="1.4"/>
|
||||
<path d="M12.4346 3.62988C13.0533 3.63005 13.3559 3.62244 13.873 3.70996C14.1827 3.76237 14.4159 3.83987 14.6787 3.94629L14.9551 4.0625C17.2877 5.0584 18.9277 7.39184 18.9277 10.1143V14.6738C18.9277 15.7754 19.438 16.8147 20.3086 17.4844C20.5835 17.6958 20.9084 17.9327 21.2842 18.1865C21.3461 18.2283 21.3876 18.3013 21.3877 18.3838V20.2969C21.3876 20.3982 21.3261 20.4829 21.2432 20.5166C20.4748 20.8286 18.2879 21.3262 12.4277 21.3262C6.93322 21.3262 4.66803 20.8889 3.77344 20.5771L3.6123 20.5166C3.52941 20.4829 3.46882 20.3982 3.46875 20.2969V18.3838C3.46882 18.322 3.4918 18.2657 3.5293 18.2236L3.57129 18.1865C3.94707 17.9327 4.27194 17.6958 4.54688 17.4844C5.41738 16.8149 5.9276 15.7764 5.92773 14.6748V10.1562C5.92773 6.92932 7.89268 4.10108 10.9541 3.71484C11.539 3.64107 11.8416 3.62973 12.4346 3.62988Z" fill="#F5F5F5" stroke="#808080" stroke-width="1.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4.06082 3.75646C4.2774 3.8327 4.50791 3.86117 4.73652 3.83993C4.96514 3.81868 5.18646 3.74823 5.38528 3.63339C5.58411 3.51856 5.75573 3.36207 5.88838 3.17465C6.02102 2.98724 6.11155 2.77335 6.15374 2.54766L6.4084 1.1789C7.4555 0.940367 8.54285 0.940367 9.58996 1.1789L9.8462 2.54766C9.8884 2.77335 9.97892 2.98724 10.1116 3.17465C10.2442 3.36207 10.4158 3.51856 10.6147 3.63339C10.8135 3.74823 11.0348 3.81868 11.2634 3.83993C11.492 3.86117 11.7226 3.8327 11.9391 3.75646L13.2514 3.2949C13.9818 4.08176 14.5261 5.02277 14.8438 6.04833L13.7854 6.95553C13.611 7.10495 13.4711 7.29031 13.3752 7.49889C13.2793 7.70748 13.2296 7.93435 13.2296 8.16394C13.2296 8.39353 13.2793 8.6204 13.3752 8.82898C13.4711 9.03757 13.611 9.22293 13.7854 9.37234L14.8438 10.2787C14.5261 11.3043 13.9818 12.2453 13.2514 13.0322L11.9383 12.5706C11.7218 12.4944 11.4912 12.4659 11.2626 12.4872C11.034 12.5084 10.8127 12.5789 10.6139 12.6937C10.415 12.8085 10.2434 12.965 10.1108 13.1524C9.97813 13.3398 9.8876 13.5537 9.84541 13.7794L9.59155 15.1482C8.54444 15.3867 7.4571 15.3867 6.40999 15.1482L6.15374 13.7794C6.11155 13.5537 6.02102 13.3398 5.88838 13.1524C5.75573 12.965 5.58411 12.8085 5.38528 12.6937C5.18646 12.5789 4.96514 12.5084 4.73652 12.4872C4.50791 12.4659 4.2774 12.4944 4.06082 12.5706L2.74856 13.0322C2.0181 12.2453 1.47389 11.3043 1.15619 10.2787L2.21459 9.37155C2.38877 9.22213 2.52858 9.03682 2.62443 8.82831C2.72028 8.6198 2.7699 8.39303 2.7699 8.16354C2.7699 7.93405 2.72028 7.70728 2.62443 7.49877C2.52858 7.29026 2.38877 7.10495 2.21459 6.95553L1.15619 6.04833C1.47366 5.02284 2.01759 4.08184 2.74777 3.2949L4.06082 3.75646ZM7.99997 5.77617C8.63314 5.77617 9.24038 6.0277 9.6881 6.47542C10.1358 6.92313 10.3873 7.53037 10.3873 8.16354C10.3873 8.79671 10.1358 9.40395 9.6881 9.85166C9.24038 10.2994 8.63314 10.5509 7.99997 10.5509C7.36681 10.5509 6.75957 10.2994 6.31185 9.85166C5.86413 9.40395 5.61261 8.79671 5.61261 8.16354C5.61261 7.53037 5.86413 6.92313 6.31185 6.47542C6.75957 6.0277 7.36681 5.77617 7.99997 5.77617Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,40 @@
|
||||
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4199_10908)">
|
||||
<g filter="url(#filter0_di_4199_10908)">
|
||||
<circle cx="17.5" cy="17.5" r="17.5" fill="#FDFDFD"/>
|
||||
<circle cx="17.5" cy="17.5" r="17" stroke="url(#paint0_linear_4199_10908)" stroke-opacity="0.2"/>
|
||||
</g>
|
||||
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
|
||||
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
|
||||
<path d="M15.7661 32.1768L16.9463 25.8849C16.9619 25.8022 17.0341 25.7422 17.1183 25.7422H17.7667C17.8518 25.7422 17.9246 25.8034 17.9391 25.8873L19.0289 32.1811C19.0379 32.2325 19.0233 32.2853 18.9893 32.3249L17.5726 33.975C17.504 34.0548 17.3811 34.0565 17.3103 33.9787L15.8086 32.3268C15.7717 32.2862 15.7559 32.2307 15.7661 32.1768Z" fill="#70B62B"/>
|
||||
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
|
||||
<rect x="22.451" y="12.5248" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5248)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_di_4199_10908" x="-2" y="-1" width="39" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4199_10908"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4199_10908" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.25"/>
|
||||
<feGaussianBlur stdDeviation="1.5"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_4199_10908"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4199_10908" x1="1.09375" y1="1.59091" x2="35" y2="1.59091" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#C0FF7B"/>
|
||||
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_4199_10908">
|
||||
<rect width="35" height="35" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26" fill="none">
|
||||
<g clip-path="url(#clip0_9701_40694)">
|
||||
<rect width="26" height="26" rx="2" fill="white"></rect>
|
||||
<path d="M0 23.2893L0 2.71C0 1.21331 1.21331 0 2.71 0L23.29 0C24.7867 0 26 1.21331 26 2.71V23.2893C26 24.7864 24.7864 26 23.2893 26H2.71067C1.21361 26 0 24.7864 0 23.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||
<path d="M0 7.5L0 2.71067C0 1.21361 1.21361 0 2.71067 0L23.2893 0C24.7864 0 26 1.21361 26 2.71067V7.5H0Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||
<circle cx="2.75" cy="3.75" r="0.75" fill="#70B62B"></circle>
|
||||
<circle cx="4.95001" cy="3.75" r="0.75" fill="#70B62B"></circle>
|
||||
<circle cx="7.15002" cy="3.75" r="0.75" fill="#70B62B"></circle>
|
||||
<path d="M10 4H24" stroke="#70B62B" stroke-width="0.9" stroke-linecap="square"></path>
|
||||
<rect x="2.45" y="10.45" width="9.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
|
||||
<rect x="2.45" y="15.45" width="14.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
|
||||
<rect x="7.45" y="20.45" width="16.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9701_40694">
|
||||
<rect width="26" height="26" rx="2" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 7.5H19.5341" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 12.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 17.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.0755 4.16406L3.39658 4.16406C2.71318 4.16406 2.15918 4.71777 2.15918 5.4008L2.15918 19.0967C2.15918 19.7797 2.71318 20.3335 3.39658 20.3335H21.0755C21.7589 20.3335 22.3129 19.7797 22.3129 19.0967V5.4008C22.3129 4.71777 21.7589 4.16406 21.0755 4.16406Z" stroke="#808080" stroke-width="1.15" stroke-miterlimit="10"/>
|
||||
<path d="M9.10286 12.375L2.60676 18.15C1.74915 18.9153 2.28822 20.3357 3.43986 20.3357H21.033C22.1846 20.3357 22.7237 18.9153 21.8661 18.15L15.37 12.375" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.6101 14.5783L21.8661 6.34978C22.7237 5.58447 22.1846 4.16406 21.033 4.16406L3.43986 4.16406C2.28822 4.16406 1.74915 5.58447 2.60676 6.34978L11.8628 14.5783C12.0772 14.7681 12.4018 14.7681 12.6101 14.5783Z" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1,14 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_mobile_spreed)">
|
||||
<path d="M11.1875 19.743V18.7995C11.1875 18.3126 11.5822 17.918 12.069 17.918H13.3336C13.8204 17.918 14.2151 18.3126 14.2151 18.7995V19.743C14.2151 20.2298 13.8204 20.6245 13.3336 20.6245H12.069C11.5822 20.6245 11.1875 20.2298 11.1875 19.743Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||
<path d="M7.09047 15.9141C8.56573 17.3894 10.4773 18.1623 12.4098 18.2329C14.5365 18.3106 16.6884 17.5376 18.3119 15.9141C21.4106 12.8154 21.4106 7.7915 18.3119 4.69274C15.2131 1.59398 10.1892 1.59398 7.09047 4.69274C3.99171 7.7915 3.99171 12.8154 7.09047 15.9141Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.1764 12.8321C10.8403 13.496 11.7005 13.8438 12.5701 13.8755C13.527 13.9105 14.4954 13.5627 15.2259 12.8321C16.6203 11.4377 16.6203 9.17699 15.2259 7.78258C13.8315 6.38816 11.5708 6.38816 10.1764 7.78258C8.782 9.17699 8.782 11.4377 10.1764 12.8321Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.1779 5.30035C12.3155 5.43795 12.4938 5.51003 12.674 5.51662C12.8723 5.52386 13.073 5.45177 13.2245 5.30035C13.5135 5.01134 13.5135 4.54278 13.2245 4.25376C12.9355 3.96475 12.4669 3.96475 12.1779 4.25376C11.8889 4.54278 11.8889 5.01134 12.1779 5.30035Z" fill="#808080"/>
|
||||
<path d="M7.47754 22.1609V21.2174C7.47754 20.7306 7.8722 20.3359 8.35905 20.3359H17.0433C17.5302 20.3359 17.9248 20.7306 17.9248 21.2174V22.1609C17.9248 22.6478 17.5302 23.0425 17.0433 23.0425H8.35905C7.87221 23.0425 7.47754 22.6478 7.47754 22.1609Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_mobile_spreed">
|
||||
<rect width="22.0417" height="23" fill="white" transform="translate(1.47949 1)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 20.0191V4.98038C3 3.88665 3.88665 3 4.98038 3H20.0196C21.1133 3 22 3.88665 22 4.98038V20.0191C22 21.1131 21.1131 22 20.0191 22H4.98087C3.88687 22 3 21.1131 3 20.0191Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||
<path d="M3 8.48077V4.98087C3 3.88687 3.88687 3 4.98087 3H20.0191C21.1131 3 22 3.88687 22 4.98087V8.48077H3Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||
<mask id="path-3-inside-1_mobile_tasks" fill="white">
|
||||
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769"/>
|
||||
</mask>
|
||||
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-3-inside-1_mobile_tasks)"/>
|
||||
<mask id="path-4-inside-2_mobile_tasks" fill="white">
|
||||
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769"/>
|
||||
</mask>
|
||||
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-4-inside-2_mobile_tasks)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.9847 20L7.43923 13.1999C7.01817 12.8204 7.01861 12.1598 7.44019 11.7809L14.9847 5" stroke="#808080" stroke-width="1.73077" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 264 B |
@@ -13,21 +13,19 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -53,13 +51,12 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
// Живой forbion (_header.css .app-menu--mobile): нижний отступ 90 под плавающую панель,
|
||||
// иконки 62, сетка 3 колонки с gap 8, БЕЗ скругления верха и БЕЗ ручки-хендла,
|
||||
// высота — по контенту (почти весь экран), фон #FBFBFB.
|
||||
private val BottomBarReserve: Dp = 90.dp
|
||||
private val MenuIconSize = 62.dp
|
||||
private val MenuGridGap = 20.dp
|
||||
|
||||
// Меню-лист выезжает снизу и занимает ~пол-экрана (не весь), по требованию владельца
|
||||
private const val MenuSheetHeightFraction = 0.6f
|
||||
private val MenuSheetShape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
private val MenuGridGap = 8.dp
|
||||
|
||||
data class F7AppMenuItem(
|
||||
val label: String,
|
||||
@@ -110,7 +107,8 @@ fun F7AppMenuSheet(
|
||||
),
|
||||
)
|
||||
}
|
||||
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень
|
||||
// Сам лист — выезжает снизу вверх (translateY 100%→0), как в живом forbion:
|
||||
// без скругления верха и без ручки, высота по контенту до почти всего экрана.
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(250)) + slideInVertically(
|
||||
@@ -126,61 +124,47 @@ fun F7AppMenuSheet(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(MenuSheetHeightFraction)
|
||||
.padding(bottom = BottomBarReserve)
|
||||
.navigationBarsPadding()
|
||||
.shadow(16.dp, MenuSheetShape, clip = false)
|
||||
.clip(MenuSheetShape)
|
||||
.heightIn(max = 640.dp)
|
||||
.background(F7Colors.Background)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
.navigationBarsPadding()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = BottomBarReserve)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Ручка-хендл
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 2.dp, bottom = 10.dp)
|
||||
.width(40.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(F7Colors.Border),
|
||||
)
|
||||
F7AppMenuSearchField(
|
||||
serverUrl = base,
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 20.dp),
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
// Сетка 3 колонки, gap 8 (живой forbion). Не-ленивая — лист сам в verticalScroll.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = filteredItems,
|
||||
key = { index, item -> "${item.label}-$index" },
|
||||
) { index, item ->
|
||||
val originalIndex = items.indexOf(item)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
F7AppMenuGridItem(
|
||||
item = item,
|
||||
onClick = {
|
||||
if (originalIndex >= 0) {
|
||||
onItemClick(originalIndex)
|
||||
filteredItems.chunked(3).forEach { rowItems ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
) {
|
||||
rowItems.forEach { item ->
|
||||
val originalIndex = items.indexOf(item)
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
F7AppMenuGridItem(
|
||||
item = item,
|
||||
onClick = { if (originalIndex >= 0) onItemClick(originalIndex) },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
// добить ряд пустыми ячейками для выравнивания по левому краю
|
||||
repeat(3 - rowItems.size) { Box(modifier = Modifier.weight(1f)) {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,31 +235,34 @@ private fun F7AppMenuGridItem(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val localIcon = item.localIcon
|
||||
if (localIcon != null) {
|
||||
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(MenuIconSize)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(1.5.dp, F7Colors.Green30, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
|
||||
// как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
|
||||
val frameShape = CircleShape
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(MenuIconSize)
|
||||
.shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
|
||||
.clip(frameShape)
|
||||
.background(Color.White)
|
||||
.border(1.dp, Color(0xFFECECEC), frameShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val localIcon = item.localIcon
|
||||
if (localIcon != null) {
|
||||
Icon(
|
||||
localIcon,
|
||||
contentDescription = item.label,
|
||||
tint = F7Colors.Primary,
|
||||
modifier = Modifier.size(MenuIconSize * 0.46f),
|
||||
modifier = Modifier.size(MenuIconSize * 0.5f),
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize * 0.56f),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
|
||||
@@ -4,7 +4,7 @@ enum class F7BottomBarSlot {
|
||||
Chats,
|
||||
NavBack,
|
||||
SectionMail,
|
||||
SectionCards,
|
||||
SectionTasks,
|
||||
SectionConferences,
|
||||
Menu,
|
||||
}
|
||||
@@ -15,10 +15,8 @@ data class F7BottomBarConfig(
|
||||
val buttonCount: Int get() = slots.size
|
||||
|
||||
companion object {
|
||||
// Единая навигационная панель по дизайну «Новое меню»:
|
||||
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
|
||||
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
|
||||
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
|
||||
// Нижняя панель 1:1 с мобильным сайтом forbion (layout.user.php):
|
||||
// «назад», затем ярлыки-разделы Почта · Задачи · Конференции, справа — меню.
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun forContext(
|
||||
tabKey: String,
|
||||
@@ -27,7 +25,7 @@ data class F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.SectionMail,
|
||||
F7BottomBarSlot.SectionCards,
|
||||
F7BottomBarSlot.SectionTasks,
|
||||
F7BottomBarSlot.SectionConferences,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
|
||||
@@ -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,10 @@ 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.wrapContentWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -29,22 +30,30 @@ data class F7BottomBarActions(
|
||||
val onChatsClick: () -> Unit = {},
|
||||
val onNavBackClick: () -> Unit = {},
|
||||
val onMailClick: () -> Unit = {},
|
||||
val onCardsClick: () -> Unit = {},
|
||||
val onTasksClick: () -> Unit = {},
|
||||
val onConferencesClick: () -> Unit = {},
|
||||
val onMenuClick: () -> Unit = {},
|
||||
)
|
||||
|
||||
private val BottomBarButtonSize = 55.dp
|
||||
private val BottomBarIconSize = 34.dp
|
||||
// Иконки нижней панели — ЛОКАЛЬНЫЕ mobile-*-icon из живой темы (assets/menu/), 1:1 с сайтом.
|
||||
private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
|
||||
|
||||
// Иконки-разделы (Почта/Карточки/Конференции) — серверные SVG с внутренним
|
||||
// «воздухом», поэтому рендерим крупнее, чтобы заполняли кружок.
|
||||
private val BottomBarSectionIconSize = 46.dp
|
||||
private val BottomBarGap = 8.dp
|
||||
private val BottomBarOuterPaddingH = 6.dp
|
||||
private val BottomBarOuterPaddingV = 6.dp
|
||||
// Живой forbion (_header.css .header__mobile-bottom): пилюля 328×61, паддинг 3,
|
||||
// фон #F5F5F5; кнопки 55, фон #FBFBFB, рамка #E6E6E6; активная кнопка-раздел и открытый
|
||||
// бургер — полупрозрачный зелёный градиент 104°.
|
||||
private val BottomBarWidth = 328.dp
|
||||
private val BottomBarHeight = 61.dp
|
||||
private val BottomBarButtonSize = 55.dp
|
||||
|
||||
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
|
||||
private val BottomBarIconSize = 25.dp
|
||||
private val BottomBarSectionIconSize = 25.dp
|
||||
private val BottomBarBackIconSize = 18.dp
|
||||
private val BottomBarOuterPadding = 3.dp
|
||||
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
||||
private val BottomBarBorderBrush = Brush.linearGradient(
|
||||
private val BottomBarButtonBg = Color(0xFFFBFBFB)
|
||||
private val BottomBarButtonBorder = Color(0xFFE6E6E6)
|
||||
private val BottomBarActiveBrush = Brush.linearGradient(
|
||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||
)
|
||||
|
||||
@@ -62,10 +71,11 @@ fun F7MobileBottomBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val pillShape = RoundedCornerShape(percent = 50)
|
||||
val pillShape = RoundedCornerShape(100.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.wrapContentWidth()
|
||||
.width(BottomBarWidth)
|
||||
.height(BottomBarHeight)
|
||||
.shadow(
|
||||
elevation = 2.dp,
|
||||
shape = pillShape,
|
||||
@@ -73,11 +83,8 @@ fun F7MobileBottomBar(
|
||||
)
|
||||
.clip(pillShape)
|
||||
.background(Color(0xFFF5F5F5))
|
||||
.padding(
|
||||
horizontal = BottomBarOuterPaddingH,
|
||||
vertical = BottomBarOuterPaddingV,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
|
||||
.padding(BottomBarOuterPadding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
config.slots.forEach { slot ->
|
||||
@@ -89,39 +96,41 @@ fun F7MobileBottomBar(
|
||||
"$base/themes/forbion/images/header/chat-icon-gray.svg"
|
||||
},
|
||||
contentDescription = "Чаты",
|
||||
active = chatsHighlighted,
|
||||
onClick = actions.onChatsClick,
|
||||
)
|
||||
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
|
||||
contentDescription = "Папки",
|
||||
iconUrl = barIcon("sidebar-chevron-left.svg"),
|
||||
contentDescription = "Назад",
|
||||
iconSize = BottomBarBackIconSize,
|
||||
iconRotation = if (navBackHighlighted) 180f else 0f,
|
||||
onClick = actions.onNavBackClick,
|
||||
)
|
||||
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg",
|
||||
iconUrl = barIcon("mobile-mail-icon.svg"),
|
||||
contentDescription = "Почта",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
active = activeTabKey.equals("mail", ignoreCase = true),
|
||||
onClick = actions.onMailClick,
|
||||
)
|
||||
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg",
|
||||
contentDescription = "Карточки",
|
||||
F7BottomBarSlot.SectionTasks -> F7BottomBarIconSlot(
|
||||
iconUrl = barIcon("mobile-tasks-icon.svg"),
|
||||
contentDescription = "Задачи",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
onClick = actions.onCardsClick,
|
||||
active = activeTabKey.equals("tasks", ignoreCase = true),
|
||||
onClick = actions.onTasksClick,
|
||||
)
|
||||
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg",
|
||||
iconUrl = barIcon("mobile-spreed-icon.svg"),
|
||||
contentDescription = "Конференции",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
active = activeTabKey.equals("talk", ignoreCase = true),
|
||||
onClick = actions.onConferencesClick,
|
||||
)
|
||||
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
|
||||
iconUrl = if (menuOpen) {
|
||||
"$base/themes/forbion/images/header/menu-burger-green.svg"
|
||||
} else {
|
||||
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
||||
},
|
||||
iconUrl = barIcon("mobile-burger-icon.svg"),
|
||||
contentDescription = "Меню",
|
||||
active = menuOpen,
|
||||
showBadge = showNotificationBadge,
|
||||
onClick = actions.onMenuClick,
|
||||
)
|
||||
@@ -135,19 +144,27 @@ private fun F7BottomBarIconSlot(
|
||||
iconUrl: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
active: Boolean = false,
|
||||
iconRotation: Float = 0f,
|
||||
showBadge: Boolean = false,
|
||||
iconSize: Dp = BottomBarIconSize,
|
||||
) {
|
||||
// Зелёной подложки под активной кнопкой быть не должно — фон всегда нейтральный.
|
||||
// Живой forbion: кнопка 55, фон #FBFBFB + рамка #E6E6E6; активная (текущий раздел /
|
||||
// открытый бургер) — полупрозрачный зелёный градиент 104°.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(BottomBarButtonSize)
|
||||
.clip(BottomBarButtonShape)
|
||||
.background(Color(0x99FFFFFF), BottomBarButtonShape)
|
||||
.then(
|
||||
if (active) {
|
||||
Modifier.background(BottomBarActiveBrush, BottomBarButtonShape)
|
||||
} else {
|
||||
Modifier.background(BottomBarButtonBg, BottomBarButtonShape)
|
||||
},
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
brush = BottomBarBorderBrush,
|
||||
color = BottomBarButtonBorder,
|
||||
shape = BottomBarButtonShape,
|
||||
)
|
||||
.clickable(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -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<DavCalendar></ID>
|
||||
<ID>CyclomaticComplexMethod:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></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<DavSharee></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List<DavEvent></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List<DavTask></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>></ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List<DavTrashEvent></ID>
|
||||
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>></ID>
|
||||
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List<DavContact></ID>
|
||||
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String, bookUrl: String): List<DavContact></ID>
|
||||
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></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 <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></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>
|
||||
|
||||
@@ -25,6 +25,10 @@ data class DavCalendar(
|
||||
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||
val ctag: String = "",
|
||||
// supported-calendar-component-set: поддерживает ли коллекция VEVENT. VTODO-only списки
|
||||
// задач не принимают события → PUT VEVENT туда возвращает 403. По умолчанию true (если
|
||||
// сервер не вернул свойство — не ломаем поведение).
|
||||
val supportsEvents: Boolean = true,
|
||||
)
|
||||
|
||||
data class DavEvent(
|
||||
@@ -50,6 +54,7 @@ data class DavEvent(
|
||||
val organizerName: String = "",
|
||||
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||
val conferenceUri: String = "",
|
||||
val attachments: List<CalendarAttachmentData> = emptyList(),
|
||||
)
|
||||
|
||||
data class DavTrashEvent(
|
||||
@@ -70,11 +75,15 @@ data class DavTask(
|
||||
val status: String,
|
||||
val due: String,
|
||||
val dueRaw: String,
|
||||
/** DTSTART задачи (сырой ICS). Нужен «умным спискам» Tasks (Текущие/Сегодня/На неделе). */
|
||||
val startRaw: String = "",
|
||||
val priority: Int,
|
||||
val percentComplete: Int,
|
||||
val description: String,
|
||||
val calendarName: String,
|
||||
val calendarHref: String,
|
||||
/** UID родительской задачи (RELATED-TO;RELTYPE=PARENT) — для подзадач; пусто у корневых. */
|
||||
val parentUid: String = "",
|
||||
) {
|
||||
val isCompleted: Boolean
|
||||
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
|
||||
@@ -87,6 +96,11 @@ object CalDavClient {
|
||||
private val icsDue = Pattern.compile("DUE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val icsStatus = Pattern.compile("STATUS:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val icsUid = Pattern.compile("UID:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.
|
||||
private val icsRelatedTo = Pattern.compile(
|
||||
"RELATED-TO(?:;[^:\\r\\n]*)?:([^\\r\\n]+)",
|
||||
Pattern.CASE_INSENSITIVE,
|
||||
)
|
||||
private val icsDtStamp = Pattern.compile("DTSTAMP[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val icsLastModified = Pattern.compile("LAST-MODIFIED[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
private val icsPriority = Pattern.compile("PRIORITY:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||
@@ -105,8 +119,8 @@ object CalDavClient {
|
||||
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||
@@ -291,11 +305,27 @@ object CalDavClient {
|
||||
}
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||
error("CalDAV put event HTTP ${response.code}")
|
||||
error("CalDAV put event HTTP ${response.code}${davErrorDetail(response)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Из тела sabre-ошибки достаём человекочитаемую причину (precondition/сообщение). */
|
||||
private fun davErrorDetail(response: okhttp3.Response): String {
|
||||
val body = runCatching { response.peekBody(4096).string() }.getOrNull().orEmpty()
|
||||
if (body.isBlank()) return ""
|
||||
// sabre: <s:message>…</s:message> и имя нарушенного precondition-элемента.
|
||||
val message = Regex("<s:message>(.*?)</s:message>", RegexOption.DOT_MATCHES_ALL)
|
||||
.find(body)?.groupValues?.getOrNull(1)?.trim()
|
||||
val precondition = Regex("<cal:(supported-calendar-component-set|valid-calendar-object-resource|calendar-collection-location-ok)")
|
||||
.find(body)?.groupValues?.getOrNull(1)
|
||||
?: Regex("<d:(need-privileges)").find(body)?.groupValues?.getOrNull(1)
|
||||
return buildString {
|
||||
if (precondition != null) append(" ($precondition)")
|
||||
if (!message.isNullOrBlank()) append(": ${message.take(180)}")
|
||||
}
|
||||
}
|
||||
|
||||
fun createCalendar(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String,
|
||||
@@ -332,6 +362,122 @@ object CalDavClient {
|
||||
return DavCalendar(href = url, displayName = displayName)
|
||||
}
|
||||
|
||||
/** PROPPATCH: переименование и/или смена цвета календаря (null = не менять). */
|
||||
fun updateCalendarProps(
|
||||
client: OkHttpClient,
|
||||
calendarHref: String,
|
||||
displayName: String? = null,
|
||||
color: String? = null,
|
||||
) {
|
||||
if (displayName == null && color == null) return
|
||||
val props = buildString {
|
||||
if (displayName != null) append("<d:displayname>${escapeXml(displayName)}</d:displayname>")
|
||||
if (color != null) append("<ical:calendar-color>${escapeXml(color)}</ical:calendar-color>")
|
||||
}
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:set><d:prop>$props</d:prop></d:set>
|
||||
</d:propertyupdate>
|
||||
""".trimIndent()
|
||||
val req = Request.Builder()
|
||||
.url(calendarHref.trimEnd('/') + "/")
|
||||
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code !in 200..299) error("CalDAV proppatch HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCalendar(client: OkHttpClient, calendarHref: String) {
|
||||
val req = Request.Builder().url(calendarHref.trimEnd('/') + "/").delete().build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
|
||||
error("CalDAV delete calendar HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DavSharee(
|
||||
val principal: String, // principal:principals/users/<id> или groups/<id>
|
||||
val displayName: String,
|
||||
val writable: Boolean,
|
||||
)
|
||||
|
||||
/** Текущие получатели шаринга календаря (oc:invite). */
|
||||
fun listCalendarShares(client: OkHttpClient, calendarHref: String): List<DavSharee> {
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop><oc:invite/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, calendarHref.trimEnd('/') + "/", depth = 0, body)
|
||||
val out = mutableListOf<DavSharee>()
|
||||
val parser = newParser(xml)
|
||||
var principal = ""
|
||||
var name = ""
|
||||
var writable = false
|
||||
var inUser = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||
"user" -> { inUser = true; principal = ""; name = ""; writable = false }
|
||||
"href" -> if (inUser) principal = parser.readText().trim()
|
||||
"common-name" -> if (inUser) name = parser.readText().trim()
|
||||
"read-write" -> if (inUser) writable = true
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "user" && inUser) {
|
||||
if (principal.isNotBlank()) {
|
||||
out += DavSharee(
|
||||
principal = principal,
|
||||
displayName = name.ifBlank { principal.substringAfterLast('/') },
|
||||
writable = writable,
|
||||
)
|
||||
}
|
||||
inUser = false
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Пошарить календарь пользователю/группе (sabre sharing POST). */
|
||||
fun shareCalendar(client: OkHttpClient, calendarHref: String, principal: String, writable: Boolean) {
|
||||
val rw = if (writable) "<o:read-write/>" else ""
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
|
||||
<o:set>
|
||||
<d:href>${escapeXml(principal)}</d:href>
|
||||
<o:summary/>$rw
|
||||
</o:set>
|
||||
</o:share>
|
||||
""".trimIndent()
|
||||
sharePost(client, calendarHref, body)
|
||||
}
|
||||
|
||||
fun unshareCalendar(client: OkHttpClient, calendarHref: String, principal: String) {
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
|
||||
<o:remove><d:href>${escapeXml(principal)}</d:href></o:remove>
|
||||
</o:share>
|
||||
""".trimIndent()
|
||||
sharePost(client, calendarHref, body)
|
||||
}
|
||||
|
||||
private fun sharePost(client: OkHttpClient, calendarHref: String, body: String) {
|
||||
val req = Request.Builder()
|
||||
.url(calendarHref.trimEnd('/') + "/")
|
||||
.post(body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
if (response.code !in 200..299) error("CalDAV share HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
|
||||
fun createSubscription(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String,
|
||||
@@ -586,6 +732,7 @@ object CalDavClient {
|
||||
organizerName = parsed.organizerName,
|
||||
alarms = parsed.alarms,
|
||||
conferenceUri = parsed.conferenceUri,
|
||||
attachments = parsed.attachments,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -634,6 +781,7 @@ object CalDavClient {
|
||||
dueDate: LocalDate? = null,
|
||||
priority: Int = 0,
|
||||
description: String = "",
|
||||
parentUid: String = "",
|
||||
): DavTask {
|
||||
val uid = "${UUID.randomUUID()}@f7cloud.mobile"
|
||||
val ics = buildVTodoIcs(
|
||||
@@ -644,6 +792,7 @@ object CalDavClient {
|
||||
priority = priority,
|
||||
percentComplete = 0,
|
||||
description = description,
|
||||
parentUid = parentUid,
|
||||
)
|
||||
val url = calendar.href.trimEnd('/') + "/$uid.ics"
|
||||
val req = Request.Builder()
|
||||
@@ -670,6 +819,7 @@ object CalDavClient {
|
||||
description = description,
|
||||
calendarName = calendar.displayName,
|
||||
calendarHref = calendar.href,
|
||||
parentUid = parentUid,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -691,6 +841,7 @@ object CalDavClient {
|
||||
priority = priority,
|
||||
percentComplete = percentComplete,
|
||||
description = description,
|
||||
parentUid = task.parentUid, // не терять связь с родителем при правке подзадачи
|
||||
)
|
||||
val req = Request.Builder()
|
||||
.url(task.href)
|
||||
@@ -788,6 +939,99 @@ object CalDavClient {
|
||||
.format(instant)
|
||||
}
|
||||
|
||||
/** Занятый интервал участника (free/busy), millis UTC. */
|
||||
data class BusyPeriod(val startEpochMilli: Long, val endEpochMilli: Long)
|
||||
|
||||
/**
|
||||
* Запрос занятости участников через scheduling outbox (iTIP VFREEBUSY REQUEST).
|
||||
* Возвращает email → занятые интервалы; участник без данных — пустой список.
|
||||
*/
|
||||
fun freeBusy(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
userId: String,
|
||||
organizerEmail: String,
|
||||
attendeeEmails: List<String>,
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
): Map<String, List<BusyPeriod>> {
|
||||
if (organizerEmail.isBlank() || attendeeEmails.isEmpty()) return emptyMap()
|
||||
val outbox = calendarsBase(serverUrl, userId).trimEnd('/') + "/outbox/"
|
||||
val ics = buildString {
|
||||
appendLine("BEGIN:VCALENDAR")
|
||||
appendLine("VERSION:2.0")
|
||||
appendLine("PRODID:-//F7cloud Mobile//EN")
|
||||
appendLine("METHOD:REQUEST")
|
||||
appendLine("BEGIN:VFREEBUSY")
|
||||
appendLine("UID:${UUID.randomUUID()}@f7cloud.mobile")
|
||||
appendLine("DTSTAMP:${formatCalDavTime(Instant.now())}")
|
||||
appendLine("DTSTART:${formatCalDavTime(rangeStart)}")
|
||||
appendLine("DTEND:${formatCalDavTime(rangeEnd)}")
|
||||
appendLine("ORGANIZER:mailto:$organizerEmail")
|
||||
attendeeEmails.forEach { appendLine("ATTENDEE:mailto:$it") }
|
||||
appendLine("END:VFREEBUSY")
|
||||
appendLine("END:VCALENDAR")
|
||||
}
|
||||
val req = Request.Builder()
|
||||
.url(outbox)
|
||||
.post(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
|
||||
.build()
|
||||
val xml = client.newCall(req).execute().use { response ->
|
||||
if (!response.isSuccessful) error("CalDAV free-busy HTTP ${response.code}")
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
return parseFreeBusyResponse(xml)
|
||||
}
|
||||
|
||||
private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>> {
|
||||
val out = mutableMapOf<String, List<BusyPeriod>>()
|
||||
val parser = newParser(xml)
|
||||
var recipient = ""
|
||||
var calendarData = StringBuilder()
|
||||
var inCalendarData = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||
"response" -> { recipient = ""; calendarData = StringBuilder() }
|
||||
"href" -> if (recipient.isBlank()) recipient = parser.readText().trim()
|
||||
"calendar-data" -> { inCalendarData = true; calendarData = StringBuilder() }
|
||||
}
|
||||
XmlPullParser.TEXT -> if (inCalendarData) calendarData.append(parser.text)
|
||||
XmlPullParser.END_TAG -> when (parser.localTag()) {
|
||||
"calendar-data" -> inCalendarData = false
|
||||
"response" -> if (recipient.isNotBlank()) {
|
||||
val email = recipient.removePrefix("mailto:").trim()
|
||||
out[email] = parseBusyPeriods(calendarData.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private val freeBusyLine = Pattern.compile("^FREEBUSY[^:]*:(.+)$", Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
|
||||
|
||||
private fun parseBusyPeriods(ics: String): List<BusyPeriod> {
|
||||
val unfolded = ics.replace(Regex("\\r?\\n[ \\t]"), "")
|
||||
val out = mutableListOf<BusyPeriod>()
|
||||
val m = freeBusyLine.matcher(unfolded)
|
||||
while (m.find()) {
|
||||
m.group(1).orEmpty().split(',').forEach { period ->
|
||||
val parts = period.trim().split('/')
|
||||
if (parts.size != 2) return@forEach
|
||||
val start = CalendarIcs.parseIcsInstant(parts[0]) ?: return@forEach
|
||||
val end = if (parts[1].startsWith("P", ignoreCase = true)) {
|
||||
runCatching { start.plus(java.time.Duration.parse(parts[1])) }.getOrNull() ?: return@forEach
|
||||
} else {
|
||||
CalendarIcs.parseIcsInstant(parts[1]) ?: return@forEach
|
||||
}
|
||||
out += BusyPeriod(start.toEpochMilli(), end.toEpochMilli())
|
||||
}
|
||||
}
|
||||
return out.sortedBy { it.startEpochMilli }
|
||||
}
|
||||
|
||||
private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> {
|
||||
val parser = newParser(xml)
|
||||
val out = mutableListOf<DavCalendar>()
|
||||
@@ -798,6 +1042,7 @@ object CalDavClient {
|
||||
var calendarColor: String? = null
|
||||
var ctag = ""
|
||||
var isCollection = false
|
||||
var comps = mutableSetOf<String>()
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||
@@ -808,8 +1053,12 @@ object CalDavClient {
|
||||
calendarColor = null
|
||||
ctag = ""
|
||||
isCollection = false
|
||||
comps = mutableSetOf()
|
||||
}
|
||||
"collection" -> if (inResponse) isCollection = true
|
||||
"comp" -> if (inResponse) {
|
||||
parser.getAttributeValue(null, "name")?.trim()?.uppercase()?.let { comps += it }
|
||||
}
|
||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
||||
"getctag" -> if (inResponse) ctag = parser.readText().trim()
|
||||
@@ -826,7 +1075,15 @@ object CalDavClient {
|
||||
val name = displayName.ifBlank {
|
||||
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
||||
}
|
||||
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
|
||||
// Свойство не вернулось (comps пуст) → считаем событийным (не ломаем).
|
||||
val supportsEvents = comps.isEmpty() || "VEVENT" in comps
|
||||
out += DavCalendar(
|
||||
href = full,
|
||||
displayName = name,
|
||||
color = calendarColor,
|
||||
ctag = ctag,
|
||||
supportsEvents = supportsEvents,
|
||||
)
|
||||
}
|
||||
}
|
||||
inResponse = false
|
||||
@@ -933,11 +1190,13 @@ object CalDavClient {
|
||||
status = status,
|
||||
due = formatIcsDateTime(dueRaw),
|
||||
dueRaw = dueRaw,
|
||||
startRaw = extractIcs(icsDtStart, ics),
|
||||
priority = extractIcs(icsPriority, ics).toIntOrNull() ?: 0,
|
||||
percentComplete = percent,
|
||||
description = extractIcs(icsDescription, ics),
|
||||
calendarName = calendarName,
|
||||
calendarHref = calendarHref,
|
||||
parentUid = extractIcs(icsRelatedTo, ics).trim(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -949,6 +1208,7 @@ object CalDavClient {
|
||||
priority: Int,
|
||||
percentComplete: Int,
|
||||
description: String,
|
||||
parentUid: String = "",
|
||||
): String {
|
||||
val now = Instant.now()
|
||||
return buildString {
|
||||
@@ -976,6 +1236,9 @@ object CalDavClient {
|
||||
if (description.isNotBlank()) {
|
||||
appendLine("DESCRIPTION:${escapeIcsText(description)}")
|
||||
}
|
||||
if (parentUid.isNotBlank()) {
|
||||
appendLine("RELATED-TO;RELTYPE=PARENT:${escapeIcsText(parentUid)}")
|
||||
}
|
||||
appendLine("END:VTODO")
|
||||
appendLine("END:VCALENDAR")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ data class CalendarAlarmData(
|
||||
val action: String = "DISPLAY",
|
||||
)
|
||||
|
||||
data class CalendarAttachmentData(
|
||||
val url: String,
|
||||
val fileName: String = "",
|
||||
val mimeType: String = "",
|
||||
)
|
||||
|
||||
data class CalendarEventData(
|
||||
val uid: String,
|
||||
val summary: String,
|
||||
@@ -40,6 +46,7 @@ data class CalendarEventData(
|
||||
val organizerName: String = "",
|
||||
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||
val conferenceUri: String = "",
|
||||
val attachments: List<CalendarAttachmentData> = emptyList(),
|
||||
)
|
||||
|
||||
object CalendarIcs {
|
||||
@@ -99,6 +106,13 @@ object CalendarIcs {
|
||||
val talkUrl = conference.ifBlank {
|
||||
if (location.contains("/call/")) location else ""
|
||||
}
|
||||
val attachments = props.filter { it.name == "ATTACH" && it.value.isNotBlank() }.map {
|
||||
CalendarAttachmentData(
|
||||
url = it.value.trim(),
|
||||
fileName = it.params["FILENAME"]?.let(::unescape).orEmpty(),
|
||||
mimeType = it.params["FMTTYPE"].orEmpty(),
|
||||
)
|
||||
}
|
||||
return CalendarEventData(
|
||||
uid = uid,
|
||||
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
|
||||
@@ -116,6 +130,7 @@ object CalendarIcs {
|
||||
organizerName = orgName,
|
||||
alarms = alarms,
|
||||
conferenceUri = talkUrl,
|
||||
attachments = attachments,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,6 +178,13 @@ object CalendarIcs {
|
||||
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
|
||||
if (data.location.isBlank()) appendLine("LOCATION:$talk")
|
||||
}
|
||||
data.attachments.forEach { att ->
|
||||
if (att.url.isNotBlank()) {
|
||||
val fmt = if (att.mimeType.isNotBlank()) ";FMTTYPE=${att.mimeType}" else ""
|
||||
val fname = if (att.fileName.isNotBlank()) ";FILENAME=${escape(att.fileName)}" else ""
|
||||
appendLine("ATTACH$fmt$fname:${att.url}")
|
||||
}
|
||||
}
|
||||
data.alarms.forEach { alarm ->
|
||||
appendLine("BEGIN:VALARM")
|
||||
appendLine("ACTION:${alarm.action}")
|
||||
|
||||
@@ -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(",", "\\,")
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ object NetworkFactory {
|
||||
clients.clear() // пересобрать производные клиенты уже с кэшем
|
||||
}
|
||||
|
||||
/** Билдер на основе базового клиента (пул/кэш) c опциональным trust-all TLS, без auth. */
|
||||
fun newBaseBuilder(trustAllCerts: Boolean = false): OkHttpClient.Builder =
|
||||
base.newBuilder()
|
||||
.callTimeout(30, TimeUnit.SECONDS)
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||
|
||||
fun newAuthedClient(
|
||||
username: String,
|
||||
appPassword: String,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Устанавливает НАСТОЯЩУЮ веб-сессию Nextcloud (cookie + CSRF) по форме `/login`, используя
|
||||
* app-пароль как пароль (он валиден для входа и обходит 2FA). Нужно для встраивания веб-viewer'ов
|
||||
* (доска Whiteboard и т.п.), которым мало Basic-auth: их Vue-XHR ходят по сессии.
|
||||
*/
|
||||
object NextcloudWebSession {
|
||||
private val requestTokenRegex = Regex(
|
||||
"""(?:name=["']requesttoken["']\s+value|<meta\s+name=["']requesttoken["']\s+content)=["']([^"']+)["']""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
/** Cookie, собранные при логине (для переноса в WebView CookieManager). */
|
||||
fun login(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
trustAllCerts: Boolean = false,
|
||||
): List<Cookie> {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val host = base.toHttpUrl().host
|
||||
val jar = MemoryCookieJar()
|
||||
val client = NetworkFactory.newBaseBuilder(trustAllCerts)
|
||||
.cookieJar(jar)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
|
||||
// 1. GET страницы логина — начальные cookie + requesttoken.
|
||||
val loginHtml = client.newCall(
|
||||
Request.Builder().url("$base/login").header("User-Agent", USER_AGENT).get().build(),
|
||||
).execute().use { it.body?.string().orEmpty() }
|
||||
val token = requestTokenRegex.find(loginHtml)?.groupValues?.getOrNull(1).orEmpty()
|
||||
|
||||
// 2. POST формы логина app-паролем — сервер ставит session-cookie.
|
||||
val form = FormBody.Builder()
|
||||
.add("user", username)
|
||||
.add("password", appPassword)
|
||||
.apply { if (token.isNotBlank()) add("requesttoken", token) }
|
||||
.add("remember_login", "1")
|
||||
.add("timezone", "UTC")
|
||||
.add("timezone_offset", "0")
|
||||
.build()
|
||||
client.newCall(
|
||||
Request.Builder()
|
||||
.url("$base/login")
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.apply { if (token.isNotBlank()) header("requesttoken", token) }
|
||||
.header("OCS-APIRequest", "true")
|
||||
.post(form)
|
||||
.build(),
|
||||
).execute().use { /* cookie осели в jar */ }
|
||||
|
||||
return jar.cookiesForHost(host)
|
||||
}
|
||||
|
||||
private const val USER_AGENT = "Mozilla/5.0 (Android) F7cloud-Mobile"
|
||||
|
||||
private class MemoryCookieJar : CookieJar {
|
||||
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
val list = store.getOrPut(url.host) { mutableListOf() }
|
||||
cookies.forEach { c ->
|
||||
list.removeAll { it.name == c.name }
|
||||
list += c
|
||||
}
|
||||
}
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||
store[url.host].orEmpty().filter { it.matches(url) }
|
||||
fun cookiesForHost(host: String): List<Cookie> = store[host].orEmpty().toList()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Карта приложений (forbion.f7cloud.ru)
|
||||
|
||||
Рабочая карта UI каждого приложения на боевом forbion — **источник, по которому фич-роли
|
||||
сверяют и строят нативный мобильный клиент**. Составляется наблюдением живого сервера
|
||||
(аккаунт `cloude`), **строго read-only** — ничего не создавать/не менять/не удалять, почту
|
||||
не отправлять (см. mail/073).
|
||||
|
||||
> ‼️ **ТОЛЬКО МОБИЛЬНАЯ ВЕРСИЯ САЙТА.** Карта строится по мобильной вёрстке forbion (узкий
|
||||
> вьюпорт ~390–420px: браузер в device-режиме, реальный телефон или эмулятор), НЕ по
|
||||
> десктопной — они принципиально разные, наш клиент повторяет мобильную. Признак мобильной
|
||||
> версии: снизу плавающая панель + шторка приложений по «трём полоскам» (как в `_mobile-*.css`),
|
||||
> а не десктопный сайдбар. Десктопный layout в карте недопустим.
|
||||
|
||||
## Источники кода (стили + кнопки окон)
|
||||
На боевом forbion приложения стоят **скомпилированными** — читаемого `src/` там нет (кроме
|
||||
`f7mail`). Поэтому «в коде найти стили и кнопки» = три локальных источника (прод-SSH для этого
|
||||
не нужен):
|
||||
|
||||
1. **Стили + карта элементов окна** — тема forbion, локально:
|
||||
`/root/claude/design-reference/forbion-theme/` (мобильные `css/pages/pages-mobile/_mobile-*.css`
|
||||
и `css/pages/app-<app>/`). Селекторы в этих CSS **перечисляют каждую кнопку/элемент окна**
|
||||
(тема их адресует для стилизации) — это и есть стили + список элементов по окнам.
|
||||
2. **Читаемые компоненты (реальные кнопки/меню/обработчики)** — апстрим-исходник под ТОЧНУЮ
|
||||
версию, установленную на forbion:
|
||||
|
||||
| Приложение | Репозиторий / источник | Версия на forbion |
|
||||
|---|---|---|
|
||||
| files | nextcloud/server → `apps/files/src` | 2.4.0 (или тема+web) |
|
||||
| mail | **локально `design-reference/forbion-apps/f7mail/src`** (форк F7) + nextcloud/mail | f7mail 0.2.3 / mail 5.7.15 |
|
||||
| calendar | nextcloud/calendar | v6.4.0 |
|
||||
| contacts | nextcloud/contacts | v8.3.10 |
|
||||
| deck | nextcloud/deck | v1.16.4 |
|
||||
| notes | nextcloud/notes | v5.0.0 |
|
||||
| notifications | nextcloud/notifications | 5.0.0-dev.0 (ближайший тег/master) |
|
||||
| tasks | nextcloud/tasks | v0.17.1 |
|
||||
| talk (conf) | форк F7 `vendor/talk-android` + nextcloud/spreed | v22.0.12 |
|
||||
|
||||
Рецепт (свой app+версия): `git clone --depth 1 --branch <тег> --filter=blob:none \
|
||||
https://github.com/nextcloud/<repo>.git`, читать `src/` (`*.vue`).
|
||||
3. **Иконки/шаблоны/скомпилированный css** всех приложений — локально
|
||||
`/root/claude/design-reference/forbion-apps/<app>/` (снято с forbion read-only).
|
||||
|
||||
Связка: тема (мобильные стили + селекторы окна) ↔ апстрим `src` (какая кнопка что делает) ↔
|
||||
живой мобильный web (как это выглядит) → полная карта.
|
||||
|
||||
## Где что лежит
|
||||
Одна карта на приложение: `docs/app-map/<module>.md`. Владелец карты = фич-роль модуля.
|
||||
Коммитит в свой файл автором `b-mob-<x>`, обычным ритуалом (`git pull --rebase` → правка).
|
||||
|
||||
| Файл | Роль | Приложение |
|
||||
|---|---|---|
|
||||
| `shell.md` | b-dev-mobile | Оболочка: нижняя панель, шторка приложений, бургер |
|
||||
| `files.md` | b-mob-files | Файлы |
|
||||
| `mail.md` | b-mob-mail | Почта (вход `cloude@forbion.ru`) |
|
||||
| `talk.md` | b-mob-conf | Конференции |
|
||||
| `tasks.md` | b-mob-tasks | Задачи |
|
||||
| `notes.md` | b-mob-notes | Заметки |
|
||||
| `notifications.md` | b-mob-notify | Уведомления (лента) |
|
||||
| `deck.md` | b-mob-deck | Карточки |
|
||||
| `calendar.md` | b-mob-calendar | Календарь |
|
||||
| `contacts.md` | b-mob-contacts | Контакты |
|
||||
|
||||
## Формат (единый для всех)
|
||||
|
||||
Карта — это **дерево переходов**: экран → его кнопки/пункты → куда каждая ведёт → экран с
|
||||
кнопками, и так вглубь. Для каждого экрана:
|
||||
|
||||
```markdown
|
||||
# Карта: <Приложение>
|
||||
|
||||
## Вход
|
||||
Как попадаем: нижняя панель / шторка / пункт меню — что именно нажать.
|
||||
|
||||
## Экран: <Название экрана>
|
||||
**Шапка:** <кнопка → действие/экран>, <кнопка → …>
|
||||
**Тело:** что показывается (список/сетка/форма/пусто-состояние).
|
||||
**Действия:**
|
||||
- «<Кнопка/пункт>» → <что делает: открывает экран X / диалог Y / выполняет действие Z>
|
||||
- (если ведёт в подэкран — раскрыть его тем же блоком: Шапка/Тело/Действия)
|
||||
- «<⋯ / long-press / контекстное меню>» → пункты: «A» → …, «B» → …
|
||||
- «<+ / FAB / создать>» → форма «<Название>»: поля <перечислить>, кнопки <Сохранить/Отмена>
|
||||
|
||||
## Экран: <Следующий>
|
||||
…
|
||||
```
|
||||
|
||||
Правила:
|
||||
- Пиши ЧТО видит пользователь и КУДА ведёт — без реализации/CSS-классов.
|
||||
- Каждую кнопку/иконку — отдельной строкой с назначением. Если назначение неочевидно —
|
||||
пометь `(?)` и опиши гипотезу.
|
||||
- Модалки/шиты/выпадающие меню — тоже экраны, раскрывай их.
|
||||
- Отмечай, где мобильная веб-версия отличается от того, что уже есть в нативном клиенте
|
||||
(`расхождение:` …) — это сразу твой бэклог на добивку.
|
||||
- Пустые состояния и состояния ошибки — фиксируй (это отдельные экраны в дизайне).
|
||||
|
||||
Готовую карту используем как чек-лист: сверка вёрстки и функционала нативного экрана идёт
|
||||
по этому дереву.
|
||||
|
||||
## Diff-списки и метрика ≤2% (программа 1:1, mail/081, mail/083)
|
||||
|
||||
После карты — по каждому приложению `docs/app-map/<app>.diff.md`: список расхождений веб↔натив.
|
||||
Функционал и дизайн считаем РАЗДЕЛЬНО, каждая ≥98%.
|
||||
|
||||
Каждый пункт: `[Ф|Д]` (функционал/дизайн) · `[open|done|n-a]` (n-a = осознанно
|
||||
мобильно-неприменимо, в знаменатель не входит, но остаётся в финальном списке).
|
||||
|
||||
На экран:
|
||||
- `Ff = 1 − open-Ф / всего-Ф` (Ф = все действия/функции окна в вебе),
|
||||
- `Fd = 1 − open-Д / всего-Д` (Д по элементам: композиция, порядок, размеры/отступы, цвет,
|
||||
типографика, иконки).
|
||||
|
||||
По приложению `Ff_app`/`Fd_app` = среднее по экранам (до 0.1%). Цель: обе ≥98%. Итерации сверки
|
||||
повторяем до порога; в конце — `Ff% / Fd%` + остаточный список (open + n-a с обоснованием).
|
||||
|
||||
Шапку diff начинайте счётчиком, например:
|
||||
```
|
||||
# Diff: <Приложение> — итерация N (ГГГГ-ММ-ДД)
|
||||
Ff: 96.4% (5 open Ф / 138) · Fd: 98.1% (3 open Д / 160) · n-a: 4
|
||||
```
|
||||
|
||||
## Медиа-просмотрщики (нативно, mail/083)
|
||||
Открытие медиа — нативным слоем (зона b-mob-files): Coil (изображения/gif/svg), Media3/ExoPlayer
|
||||
(видео/аудио, стрим по WebDAV через media3-datasource-okhttp + Basic-auth), PdfRenderer (PDF),
|
||||
Collabora (офис — есть). Роутинг по типу в `FilesViewModel.openItem`.
|
||||
|
||||
## Всплывающее меню — обязательно поимённо (mail/087)
|
||||
В каждом приложении всплывающее меню СВОЁ (контекстное «⋯»/overflow, «+»/FAB, long-press,
|
||||
выпадающие селекторы). Раскрывай КАЖДОЕ отдельным блоком с полным списком пунктов по порядку;
|
||||
в diff — построчная сверка веб↔натив (состав/порядок/иконка/подменю). Не «меню совпало» общей
|
||||
строкой — именно тут прячутся пропущенные действия.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Доска приёмки 1:1 (программа mail/081, гейт mail/084)
|
||||
|
||||
Каждое приложение итерируется: прогон → `<app>.diff.md` → правка расхождений → **повторный
|
||||
прогон для подтверждения** → если расхождения появились снова, правим → так до **Ff=Fd=100%
|
||||
(0 open)**. Роль обновляет свою строку каждую итерацию (автор `b-mob-<x>`).
|
||||
|
||||
**Гейт сборки APK (лид):** APK собираю ТОЛЬКО когда ВСЕ строки = `✅ 0` И это подтверждено
|
||||
**повторным** прогоном (столбец «Подтв.» = да). Одного «стало 0» мало — нужен ещё один проход,
|
||||
показавший, что 0 держится.
|
||||
|
||||
| Приложение | Роль | Итер. | Ff% | Fd% | open Ф | open Д | Подтв. 0 | Статус |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| shell | b-dev-mobile | 0 | — | — | — | — | — | карта |
|
||||
| files | b-mob-files | 1 | — | — | 4 | ? | нет | правки D4→D3→D2→D1 |
|
||||
| mail | b-mob-mail | 1 | — | — | ~4 | ~2 | нет | правка P1 черновики |
|
||||
| talk | b-mob-conf | 1 | 41.3 | предв. | ~29 | ? | нет | P1 меню сообщения ~закрыто (Копировать/ссылка/2×переслать/Напоминание/Ответить-лично, …01198a4); остаток P1 файл-тред+перевод эскалирую (кросс-модуль/провайдеры); далее P2 меню беседы; Fd на устройстве |
|
||||
| tasks | b-mob-tasks | 1 | 24.6 | 52.7 | ~34 | ~8 | нет | P1 закрыт в коде (умные коллекции+3-state видимость+персист, `bd56c4b`); далее P2 управление списками; ждёт девайс-сверки |
|
||||
| notes | b-mob-notes | 1 | 42.8 | 0.0 | 26 | 21 | нет | P1 read-only ядро в коде+UI (`8a8f1cf` данные + `fb120d5` VM/список/редактор/конфликт); compile+7/7 тестов+detekt OK (worktree); Fd ждёт девайс. Пинг лиду на регистрацию экрана в app-шелле |
|
||||
| notifications | b-mob-notify | 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).
|
||||
@@ -0,0 +1,168 @@
|
||||
# Diff: Календарь (Calendar) — итерация 1 (2026-07-13)
|
||||
|
||||
Ff_app: **64.0%** · Fd_app: **80.4%** (среднее по 5 экранам, невзвешенно) · n-a: Экран 5
|
||||
Appointments (решение лида mail/101 — организаторская фича, мобильно-неприменима, вне знаменателя).
|
||||
|
||||
> **P1 закрыт в коде** (итерация правок 1): разворот повторяющихся событий реализован —
|
||||
> `CalendarRecurrence` (генератор оккуренций RRULE на лету, без кэша и без `<c:expand>`,
|
||||
> mail/072) + `eventOccursOnDay` теперь RRULE-осознан; +11 юнит-тестов
|
||||
> (`CalendarRecurrenceTest`, зелёные), `:feature:calendar:compileReleaseKotlin`+`detekt` green.
|
||||
> Ждёт девайс-сверки (подтверждающий второй прогон, mail/084).
|
||||
|
||||
Сверка веб (`nextcloud/calendar v6.4.0` + тема `_mobile-calendar.css`, карта `calendar.md`) ↔
|
||||
натив (`feature/calendar`: `CalendarScreen`, `CalendarComponents`, `CalendarExtendedUi`,
|
||||
`CalendarViewModel`, `CalendarRepository`, `RecurrenceRule`). Метки: `[Ф|Д]` · `[open|done|n-a]`.
|
||||
Источник состава — из кода (mail/078); порядок/вид части экранов сверен на живом forbion
|
||||
(Browser 1 / `cloude`, mail/079). **Живую write-верификацию** пишущих пунктов (`bmob-*`,
|
||||
mail/085) добавлю следующим проходом — этот прогон из кода + read-сверки.
|
||||
|
||||
> Важно: v0.5.144 закрыл в коде бОльшую часть старых «долгов» из представления (mail/072):
|
||||
> виды Год/Список, корзина, незапланированные задачи, ответы на приглашения (Принять/Может
|
||||
> быть/Отклонить) — **уже реализованы** (`done`). Реальные открытые дыры — разворот повторений,
|
||||
> EXDATE/RECURRENCE-ID, Appointments и часть настроек. Карту `calendar.md` синхронизирую с этим
|
||||
> (в ней несколько пунктов ошибочно значились как «нет в нативе» — правлю тем же коммитом).
|
||||
|
||||
Прошу приоритизацию: предлагаемый порядок P1→P5 в конце.
|
||||
|
||||
---
|
||||
|
||||
## Экран 1. Календарь (сетка + шапка + виды) — Ff 100% · Fd 75.0%
|
||||
Ф (10, open 0):
|
||||
- [Ф][done] предыдущий/следующий период · [Ф][done] «Сегодня»
|
||||
- [Ф][done] переключатель вида: День/Неделя/Месяц/**Год/Список** (все 5, `CalendarViewMode`)
|
||||
- [Ф][done] «Создать событие» · [Ф][done] открыть навигацию (бургер)
|
||||
- [Ф][done] тап по дню → выбор дня · [Ф][done] тап по событию → открыть
|
||||
- [Ф][done] «Показать незапланированные задачи» (`loadUnscheduledTasks`)
|
||||
- [Ф][done] **разворот повторяющихся событий в сетке** — реализован: `CalendarRecurrence`
|
||||
генерирует оккуренции RRULE (DAILY/WEEKLY+BYDAY/MONTHLY/YEARLY, INTERVAL, UNTIL/COUNT) на
|
||||
лету, `eventOccursOnDay` теперь RRULE-осознан → 2-я и дальше оккуренции видны в сетке/списке/
|
||||
дне (долг №2 закрыт в коде; +11 юнит-тестов). Ждёт девайс-сверки.
|
||||
Д (8, open 2): [Д][done] безрамочная месячная сетка + гридлайны (v0.5.114) · [Д][done] «сегодня»
|
||||
в зелёном круге · [Д][done] пилюли событий (до 2 + «+N») · [Д][done] пилюли дней недели ·
|
||||
[Д][done] таймлайн День/Неделя · [Д][done] русская локализация шапки · [Д][open] пиксель-вид
|
||||
видов **Год/Список** (в коде есть, вёрстка на устройстве не сверена) · [Д][open] мобильная
|
||||
модель «месяц+список дня» — сверить скролл/порядок на девайсе.
|
||||
|
||||
## Экран 2. Сайдбар навигации — Ff 61.1% · Fd 80.0%
|
||||
Ф (18, open 7):
|
||||
- [Ф][done] «Мои календари» — список · [Ф][done] чекбокс видимости календаря
|
||||
(`CalendarColorCheckbox`/`onToggleCalendar`)
|
||||
- [Ф][done] «Изменить календарь» (для не-подписок, `onEditCalendar`)
|
||||
- [Ф][done] «Добавить календарь»: Новый / Со списком задач / Подписка по ссылке
|
||||
(`CreateBookMode`) · [Ф][done] Корзина · [Ф][done] Незапланированные задачи ·
|
||||
[Ф][done] Параметры календаря
|
||||
- [Ф][open] «Копировать публичную ссылку» · [Ф][open] «Копировать ссылку подписки»
|
||||
- [Ф][open] «Экспорт календаря» (.ics из сайдбара)
|
||||
- [Ф][open] «Добавить календарь праздников» · [Ф][open] «Добавить произвольный публичный календарь»
|
||||
- [Ф][open] «Очистить корзину» (сейчас только по одному Восстановить/Удалить навсегда)
|
||||
- [Ф][open] раздел **Appointments** «Предложенные встречи / Расписание встреч» (см. Экран 5)
|
||||
- (натив-экстра, не против паритета) «Синхронизация с телефоном» — двусторонний sync с
|
||||
календарём устройства, в вебе аналога нет.
|
||||
Д (5, open 1): [Д][done] композиция сайдбара · [Д][done] строка календаря (цвет-чекбокс+имя) ·
|
||||
[Д][done] иконки разделов (тема forbion) · [Д][done] подписки визуально read-only ·
|
||||
[Д][open] отсутствует блок Appointments (порядок/место раздела).
|
||||
|
||||
## Экран 3. Диалог «Добавить / Изменить календарь» (+шаринг) — Ff 60.0% · Fd 85.0%
|
||||
Ф (10, open 4):
|
||||
- [Ф][done] имя календаря · [Ф][done] выбор цвета (`CalendarEditBookDialog`)
|
||||
- [Ф][done] «Поделиться»: поиск получателей (`onSearchSharees`/OCS sharees) ·
|
||||
[Ф][done] добавить с правом чтение/редактирование · [Ф][done] убрать получателя
|
||||
- [Ф][done] удалить календарь
|
||||
- [Ф][open] «Напоминания по умолчанию» для этого календаря (full/part-day)
|
||||
- [Ф][open] «Не показывать меня занятым» (transparent-календарь)
|
||||
- [Ф][open] «Экспорт» из диалога · [Ф][open] «Убрать у меня» (unshare-from-me для расшаренных)
|
||||
Д (5, open ~1): [Д][done] лэйаут диалога · [Д][done] цветовые чипы · [Д][done] строки
|
||||
получателей с меткой (редактирование)/(чтение) · [Д][done] радиокнопки режима создания ·
|
||||
[Д][open] точная сверка панели шаринга по теме (права «видит конфиденциальные» отсутствуют).
|
||||
|
||||
## Экран 4. Параметры календаря (settings-шит) — Ff 15.4% · Fd 70.0%
|
||||
Ф (13, open 11):
|
||||
- [Ф][done] «Импорт календарей» (`onImportClick`) · [Ф][done] тумблеры sync-слоя устройства
|
||||
(натив-специфика)
|
||||
- [Ф][open] «Номера недель» · [Ф][open] «Выходные» · [Ф][open] «Задачи в календаре»
|
||||
- [Ф][open] «Плотность День/Неделя» · [Ф][open] «Лимит событий в Месяце»
|
||||
- [Ф][open] «Простой редактор событий» (сейчас всегда открывается полный, см. Экран 6)
|
||||
- [Ф][open] «Календарь дней рождения» · [Ф][open] «Напоминание по умолчанию» (full/part)
|
||||
- [Ф][open] «Календарь по умолчанию для входящих приглашений»
|
||||
- [Ф][open] «Настройки доступности» (availability) · [Ф][open] инфо-блок «CalDAV URL / адрес
|
||||
для iOS и macOS» (копирование)
|
||||
Д (5, open ~2): [Д][done] лэйаут шита настроек · [Д][done] строки-переключатели ·
|
||||
[Д][done] секция импорта · [Д][open] отсутствуют секции General/Editing/Availability/CalDAV ·
|
||||
[Д][open] порядок и группировка пунктов по теме.
|
||||
|
||||
## Экран 5. Appointments / Расписание встреч — `n-a` (вне метрики, mail/101)
|
||||
Решение лида: организаторская фича (публикация слотов, внешние букинги) — десктоп/веб-воркфлоу,
|
||||
на мобильном клиенте неприменима, тянет тяжёлый UI. Экран целиком `n-a`, из знаменателя исключён,
|
||||
остаётся в финальном списке с обоснованием.
|
||||
Ф (10, все `n-a`) — раздел отсутствует в нативе (осознанно):
|
||||
- [Ф][open] список расписаний встреч · [Ф][open] «Создать расписание встреч»
|
||||
- [Ф][open] поля: имя/описание/местоположение/длительность/инкремент
|
||||
- [Ф][open] «Календарь бронирования» · [Ф][open] «Календари для проверки конфликтов»
|
||||
- [Ф][open] буферы до/после · [Ф][open] макс. слотов в день / горизонт бронирования
|
||||
- [Ф][open] «Создать комнату Talk» для встречи · [Ф][open] публичная страница бронирования
|
||||
(внешний гость: выбор слота → Подтвердить)
|
||||
Д (n/a пока — экран не реализован).
|
||||
> Решено (mail/101): `n-a` — вне знаменателя.
|
||||
|
||||
## Экран 6. Окно события — Ff 83.3% · Fd 92.0%
|
||||
Ф (30, open 5):
|
||||
- [Ф][done] заголовок · [Ф][done] «Весь день» · [Ф][done] дата/время С и По (нативные пикеры) ·
|
||||
[Ф][done] многодневное (отдельная дата окончания)
|
||||
- [Ф][done] выбор календаря · [Ф][done] местоположение · [Ф][done] описание · [Ф][done] категории
|
||||
- [Ф][done] добавить/удалить напоминание (тип+смещение, несколько)
|
||||
- [Ф][done] повтор-пресет · [Ф][done] кастомный повтор (интервал/частота/дни/окончание →
|
||||
RRULE, `RecurrenceRule`)
|
||||
- [Ф][done] участники: поиск (OCS sharees) · [Ф][done] удалить участника · [Ф][done] PARTSTAT
|
||||
- [Ф][done] ответ на приглашение: Принять/Может быть/Отклонить (`respondToInvite`)
|
||||
- [Ф][done] беседа Talk: выбрать существующую / создать публичную/приватную
|
||||
- [Ф][done] вложения: из Файлов (WebDAV) · [Ф][done] загрузить с устройства · [Ф][done] удалить
|
||||
- [Ф][done] занятость free/busy (VFREEBUSY через outbox)
|
||||
- [Ф][done] сохранить · [Ф][done] копия (duplicate) · [Ф][done] удалить · [Ф][done] экспорт события
|
||||
- [Ф][open] **редактирование серии «только это / это и будущие» (RECURRENCE-ID)** — нет выбора
|
||||
области при правке повторяющегося (долг №3)
|
||||
- [Ф][open] **EXDATE** — исключение отдельной оккуренции / «удалить это повторение» (долг №3)
|
||||
- [Ф][open] опции ответов: «только приглашённые могут отвечать / любой» + «разрешить пересылку»
|
||||
- [Ф][open] роли участника (Обязательный/Необязательный/Председатель/Не участвует) + «Запросить
|
||||
ответ» — сверить полноту в нативе
|
||||
- [Ф][open] «Подробнее» (переключение простой↔полный редактор) — натив всегда открывает полный
|
||||
Д (12, open ~1): [Д][done] окно события 1:1 по теме (форк `.f7-calendar-event-view`, v0.5.144:
|
||||
pill-поля 40dp, шапка с крестиком/галочкой, высота 94%) · [Д][done] нативные пикеры ·
|
||||
[Д][done] строки участников со статус-аватаром · [Д][open] мелкая пиксель-добивка секций
|
||||
повтора/free-busy на устройстве.
|
||||
|
||||
---
|
||||
|
||||
## Всплывающие меню (mail/087) — построчная сверка
|
||||
1. **Меню действий события** (шапка окна, `EventActions`): веб — Дублировать / Экспорт /
|
||||
Удалить (+ у серии: «Удалить это повторение», «Удалить это и все будущие»).
|
||||
Натив: Копия `done` · Экспорт `done` · Удалить `done` · **варианты «это/все будущие» `open`**
|
||||
(следствие RECURRENCE-ID/EXDATE).
|
||||
2. **Меню «Добавить календарь»** (радио): веб — Новый / Со списком задач / Подписка / (Праздники /
|
||||
Произвольный публичный). Натив: Новый `done` · Со списком задач `done` · Подписка `done` ·
|
||||
Праздники `open` · Произвольный публичный `open`.
|
||||
3. **Переключатель вида** (меню «Действия»): веб — День/Неделя/Месяц/Год/Список. Натив: все 5
|
||||
`done` (в нативе — сегментированный переключатель, не дропдаун — `[Д]` отличие подачи, не Ф).
|
||||
4. **Меню вложения** (`attachMenu`): веб — Из Файлов / Загрузить с устройства. Натив: оба `done`.
|
||||
5. **Строка календаря в сайдбаре**: веб «⋯» — Изменить/Экспорт/Копировать ссылку/Удалить.
|
||||
Натив: Изменить `done` (открывает диалог с удалением) · Экспорт/Копировать-ссылку `open`.
|
||||
|
||||
## Предлагаемый порядок правок (жду приоритизацию лида)
|
||||
- **P1 — Разворот повторений в сетке/списке** (Экран 1, долг №2): ✅ **СДЕЛАНО в коде**
|
||||
(`CalendarRecurrence` + `eventOccursOnDay`, mail/072-совместимо: развёртка на лету, без кэша
|
||||
и `<c:expand>`; +11 тестов). ✅ **`BYDAY=1MO` тоже починен** (коммит 492af61): `RecurrenceRule`
|
||||
хранит/сериализует ординал (раньше терялся в `toRrule`) + разворот n-го дня месяца +4 теста.
|
||||
Остаётся девайс-сверка (подтверждающий прогон).
|
||||
- **P2 — EXDATE + RECURRENCE-ID** (Экран 6, долг №3): чтение EXDATE (не показывать удалённые
|
||||
оккуренции) → выбор области правки/удаления «это / это и будущие» → запись без затирания серии.
|
||||
- **P3 — Настройки** (Экран 4): вынести веб-параметры (номера недель/выходные/задачи-в-календаре/
|
||||
дни рождения/напоминание по умолчанию/календарь для приглашений).
|
||||
- **P4 — Сайдбар-добивка** (Экран 2/3): копирование ссылок, экспорт из сайдбара, transparent,
|
||||
напоминания по умолчанию календаря, unshare-from-me, очистить корзину.
|
||||
- **P5 — Опции ответов/роли участников + «Подробнее» simple-редактор** (Экран 6).
|
||||
- **Appointments (Экран 5)** — вне P1–P5 до решения n-a/применимости (вопрос выше).
|
||||
|
||||
## Вопросы лиду
|
||||
1. Appointments — `n-a` или в план (влияет на метрику: 51.6/67.0 против 62.0/80.4)?
|
||||
2. P1 «разворот повторений» подтверждаю как локальный (без `<c:expand>`/core, по mail/072) — ок?
|
||||
3. Живую write-сверку пишущих пунктов гоню на `bmob-*` событиях/календарях (mail/085) следующим
|
||||
проходом — приоритет device-сверки Год/Список против P1-кода?
|
||||
@@ -0,0 +1,197 @@
|
||||
# Карта: Календарь
|
||||
|
||||
Приложение «Календарь» (`/apps/calendar/`) на forbion.f7cloud.ru. Структура кнопок/меню снята
|
||||
**из кода** (метод mail/078): апстрим `nextcloud/calendar` **v6.4.0** (`src/**/*.vue` — точная
|
||||
версия с forbion, канон README) + мобильная тема forbion
|
||||
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-calendar.css`,
|
||||
`css/pages/app-calendar/`). Живой мобильный web (Browser 1, `cloude`, mail/079) — только для
|
||||
визуальной сверки «как выглядит/порядок»; **строго read-only** (mail/073): ничего не
|
||||
создаётся/не меняется/не удаляется, приглашения не отправляются, free/busy-запросы не шлются.
|
||||
Приватность: реальные названия/участники событий не фиксируются — только структура UI.
|
||||
|
||||
> Статус визуальной сверки на живом forbion: **ПРОЙДЕНА** 2026-07-12 (Browser 1 `c9284989`,
|
||||
> сессия `cloude` подтверждена `OC.getCurrentUser().uid`, мобильная вёрстка снята same-origin
|
||||
> iframe 390px — окно общее не трогал, [[f7-mobile-web-iframe-technique]]; строго read-only,
|
||||
> ничего не сохранял). Реальные русские ярлыки сверены с деревом из кода, порядок полей окна
|
||||
> события подтверждён. Сверка вскрыла разделы, которых в коде-выборке не было видно — внесены
|
||||
> ниже (Appointments, незапланированные задачи, Deck-источник). Оставшиеся `визуал(?)` —
|
||||
> условные состояния (корзина видна только при наличии удалённых, реальных событий у `cloude` нет).
|
||||
|
||||
> **F7-форк**: окно события на forbion кастомизировано (тема адресует `.f7-calendar-event-view`,
|
||||
> `.f7-invitee-status-row`, `.f7-location-talk-row`, `.property-add-talk`) — набор кнопок тот же,
|
||||
> что в апстрим-компонентах `Editor/*`, отличается вёрстка/порядок (предмет попиксельной сверки).
|
||||
|
||||
## Компоненты-источники (nextcloud/calendar v6.4.0)
|
||||
`views/Calendar.vue` (шелл: навигация + сетка) · `components/CalendarGrid.vue` (сетка) ·
|
||||
`AppNavigation/AppNavigationHeader.vue` (шапка: навигация периодов, «Создать», переключатель
|
||||
вида) · `AppNavigation/CalendarList/*` (список календарей, действия, корзина) ·
|
||||
`AppNavigation/EditCalendarModal.vue` + `EditCalendarModal/ShareItem.vue` (правка/шаринг
|
||||
календаря) · `AppNavigation/Settings.vue` (настройки) · `views/EditSimple.vue`/`EditFull.vue`
|
||||
(окно события) · `Editor/Properties/*` (поля) · `Editor/Repeat/*` (повторение) ·
|
||||
`Editor/Alarm/*` (напоминания) · `Editor/Invitees/*` + `InvitationResponseButtons.vue`
|
||||
(участники/ответы) · `Editor/Attachments/*` (вложения) · `Editor/AddTalkModal.vue` (беседа
|
||||
Talk) · `Editor/FreeBusy/*` (занятость).
|
||||
|
||||
## Вход
|
||||
Нижняя плавающая панель / шторка приложений (по «трём полоскам») → «Календарь», или прямой URL
|
||||
`/apps/calendar/`. Оболочку описывает `shell.md` (зона лида) — здесь только специфика приложения.
|
||||
|
||||
## Экран: Календарь (главный, сетка)
|
||||
**Шапка (`AppNavigationHeader`, реальные ярлыки мобильной версии сверены):**
|
||||
- «**Открыть навигацию**» (бургер) → сайдбар календарей (см. ниже).
|
||||
- «**Предыдущий месяц**» / «**Следующий месяц**» → сдвиг периода (aria-label зависит от вида:
|
||||
«…месяц»/«…неделя»/«…день»).
|
||||
- «**Сегодня**» → текущая дата.
|
||||
- «**Создать событие**» → окно события (см. «Экран: Событие»); write — на forbion не сохраняю.
|
||||
- «**Действия**» → меню переключения вида: «**День**», «**Неделя**», «**Месяц**», «**Год**»,
|
||||
«**Список**» (сверено на живом — все 5 пунктов).
|
||||
- `отличие от десктопа:` в мобильной версии переключатель вида спрятан в дропдаун «Действия»,
|
||||
а НЕ вынесен видимым тумблером.
|
||||
- `подача (Д):` в нативе все 5 видов есть (`CalendarViewMode`), но переключатель —
|
||||
сегментированный, не дропдаун; сверка подачи — в calendar.diff.md.
|
||||
|
||||
**Тело:** сетка календаря. На мобильном (узкий вьюпорт) модель компактная — месяц сверху +
|
||||
список событий выбранного дня снизу.
|
||||
|
||||
**Действия в теле:**
|
||||
- Тап по ячейке дня → выбор дня (список событий этого дня внизу).
|
||||
- Тап по пилюле события → открыть событие (просмотр/редактор).
|
||||
- «**Показать незапланированные задачи**» (сверено на живом) → шит/панель «Незапланированные
|
||||
задачи» (VTODO без даты из календарей с задачами); «×» закрыть. В нативе реализовано
|
||||
(`loadUnscheduledTasks`, `CalendarTrashSheet`-стиль шит) — `done`.
|
||||
|
||||
## Экран: Навигация календарей (сайдбар / бургер)
|
||||
Открытие: кнопка «**Открыть навигацию**» в шапке.
|
||||
**Тело (сверено на живом):** «**Мои календари**» (+ «Добавить новое»), список календарей,
|
||||
раздел «**Предложенные встречи**» / «**Расписание встреч**» (Appointments), внизу —
|
||||
«**Параметры календаря**» (+ Корзина при наличии удалённых).
|
||||
|
||||
**Календари в списке `cloude` (пример состава):** «Личное», «Дни рождения контакта»,
|
||||
«Карточки» (интеграция Deck — доски как источник событий). `визуал(?)`: набор зависит от аккаунта.
|
||||
|
||||
**Строка календаря:**
|
||||
- Чекбокс видимости → включить/выключить показ календаря.
|
||||
- Название + счётчик.
|
||||
- «**⋯**» (действия) → меню:
|
||||
- «**Изменить**» / «**Изменить и поделиться**» → модалка правки календаря (ниже). write.
|
||||
- «**Копировать публичную ссылку**» / «**Копировать ссылку подписки**».
|
||||
- «**Экспорт**» → скачивание `.ics`.
|
||||
- «**Удалить**» → подтверждение. write.
|
||||
|
||||
**Действия списка:**
|
||||
- «**+ Добавить новый**» → меню:
|
||||
- «**Новый календарь**» / «**Новый календарь со списком задач**». write.
|
||||
- «**Новая подписка по ссылке (только чтение)**». write.
|
||||
- «**Добавить календарь праздников**» / «**Добавить произвольный публичный календарь**». write.
|
||||
|
||||
### Подэкран: Корзина
|
||||
**Тело:** список удалённых календарей/событий.
|
||||
**Действия:** «**Восстановить**» (write) · «**Удалить навсегда**» (write) ·
|
||||
«**Очистить корзину**» → подтверждение «Действительно очистить корзину?» (write).
|
||||
|
||||
### Подэкран: Изменить календарь (модалка `EditCalendarModal`)
|
||||
**Тело/действия:**
|
||||
- «**Имя календаря**» + выбор цвета. write.
|
||||
- «**Поделиться календарём**» → поиск «Share with users or groups» → строка получателя:
|
||||
право «может редактировать и видеть конфиденциальные события», снять доступ. write.
|
||||
- «**Напоминания по умолчанию**» для полнодневных/частичных событий этого календаря.
|
||||
- «**Не показывать меня занятым**» (сделать календарь прозрачным).
|
||||
- «**Экспорт**» · «**Удалить**» (write) · «**Убрать у меня**» (для расшаренных, write).
|
||||
|
||||
### Подэкран: Настройки (`Settings.vue`)
|
||||
**Тело (тумблеры/пункты):** «Номера недель», «Выходные», «Задачи в календаре», «Плотность в
|
||||
День/Неделя», «Лимит событий в Месяце», «Простой редактор событий» («"Подробнее" открывает
|
||||
детальный редактор»), «Календарь дней рождения», «Напоминание по умолчанию» (полнодневные/
|
||||
частичные), «Календарь по умолчанию для входящих приглашений», «Настройки доступности» (write),
|
||||
«Импорт календарей» (write).
|
||||
- Блок «**CalDAV**»: «CalDAV URL», «Адрес сервера для iOS и macOS» — информационные (копирование).
|
||||
|
||||
### Подэкран: Расписания встреч / Appointments (`views/Appointments`, `AppointmentConfigList`)
|
||||
Раздел сайдбара «**Предложенные встречи**» / «**Расписание встреч**» (сверено на живом — у
|
||||
`cloude` пусто: «Нет активных предложенных встреч»).
|
||||
**Действия:**
|
||||
- «**Создать расписание встреч**» (`Create appointment schedule`) → модалка настройки
|
||||
(`AppointmentConfigModal`): имя, описание, местоположение, длительность, «Календарь бронирования»,
|
||||
«Календари для проверки конфликтов», буферы до/после, макс. слотов в день, ограничение горизонта
|
||||
бронирования, «Создать комнату Talk». write.
|
||||
- Готовое расписание → публичная страница бронирования (внешний гость: выбор слота → «Подтвердить»).
|
||||
- `расхождение (натив):` функционала «Appointments»/страниц бронирования в клиенте нет.
|
||||
|
||||
## Экран: Событие (окно, `EditSimple`/`EditFull`, форк `.f7-calendar-event-view`)
|
||||
> Сверено на живом (mail/079): «Создать событие» открывает СРАЗУ полный редактор
|
||||
> (`/new/full/…`, F7-форк по умолчанию полный, не simple). Порядок и русские ярлыки полей
|
||||
> подтверждены: Название → С/По (дата+время нативными пикерами) → «Добавить местоположение» →
|
||||
> «Добавьте описание» → «Добавить напоминание» → «Поиск и добавление категорий» →
|
||||
> «Поиск и добавление участников (email/пользователи/контакты/группы/команды)».
|
||||
Открытие: пилюля события или «Создать событие». Переключение простой↔полный: «**Подробнее**».
|
||||
|
||||
**Шапка:**
|
||||
- «**×**» (Закрыть) → при несохранённых правках диалог «Отменить изменения?»
|
||||
(«Точно отменить изменения этого события?»).
|
||||
- «**✓ Сохранить**» (write). У повторяющегося события → выбор «Обновить это повторение» /
|
||||
«Обновить это и все будущие» (write).
|
||||
- «**⋯**» (действия) → меню: «Дублировать» (write), «Экспорт», «Удалить» (write);
|
||||
у серии — «Удалить это повторение» / «Удалить это и все будущие».
|
||||
|
||||
**Тело — поля (`Editor/Properties`):**
|
||||
- «**Заголовок**» события. write.
|
||||
- «**Весь день**» (тумблер).
|
||||
- «**С**» / «**По**» — нативные пикеры даты/времени (в нативе совпадает).
|
||||
- Выбор «**Календаря**» (коллекции).
|
||||
- «Местоположение», «Описание», «Категории».
|
||||
- Выбор «**Цвета**» / «Убрать цвет».
|
||||
|
||||
**Тело — Повторение (`Editor/Repeat`):**
|
||||
- «**Повтор**» («Не повторяется») → меню пресетов + «**Настроить повторение**»:
|
||||
- «Повторять каждые» N + частота (день/неделя/месяц/год).
|
||||
- Неделя: кружки дней недели (Пн…Вс).
|
||||
- Месяц: «В конкретный день» / «В N-й» (первый… / будний / выходной / день).
|
||||
- «Окончание»: «никогда» / «на дату» / «после» N «повторений».
|
||||
- Предупреждение о неполной поддержке rrule (часть повторений может быть потеряна при правке).
|
||||
- `расхождение (бэклог, mail/072):` натив НЕ разворачивает оккуренции в сетке (долг №2) и не
|
||||
поддерживает EXDATE/RECURRENCE-ID (долг №3); `toRrule` не сериализует «первый понедельник»
|
||||
(`BYDAY=1MO`).
|
||||
|
||||
**Тело — Напоминания (`Editor/Alarm`):**
|
||||
- «**Добавить напоминание**» (write) → тип (Уведомление/Email/Аудио), смещение
|
||||
(«за … минут/часов/дней/недель до») или «На дату»; «Удалить напоминание».
|
||||
|
||||
**Тело — Участники / приглашения (`Editor/Invitees`, форк `.f7-invitee-status-row`):**
|
||||
- «**Участники**» («Пока нет участников») → поиск «эл. почты, пользователи, контакты, группы,
|
||||
команды». write.
|
||||
- Роль участника: «Обязательный»/«Необязательный»/«Председатель»/«Не участвует»; «Запросить
|
||||
ответ»; «Удалить участника».
|
||||
- «**Найти время**» → сетка занятости (ниже).
|
||||
- Настройка ответов: «Только приглашённые могут отвечать» / «Любой с приглашением может
|
||||
отвечать», «Разрешить пересылку».
|
||||
- «**Кнопки ответа на приглашение**» (входящее событие, `InvitationResponseButtons`):
|
||||
«**Принять**» / «**Отклонить**» / «**Под вопросом**». write (iTIP REPLY).
|
||||
- В нативе реализовано: «Принять»/«Может быть»/«Отклонить» (`respondToInvite`) — `done`
|
||||
(v0.5.144 закрыл этот долг). Открыто: опции «только приглашённые/любой» + «разрешить
|
||||
пересылку» — см. calendar.diff.md.
|
||||
|
||||
**Тело — Беседа Talk (`AddTalkModal`, форк `.property-add-talk`):**
|
||||
- «**Добавить беседу Talk**» → модалка: выбрать существующую беседу или создать
|
||||
публичную/приватную. write при создании.
|
||||
|
||||
**Тело — Вложения (`Editor/Attachments`):**
|
||||
- «**Из файлов**» → навигация по WebDAV (добавить файл / поделиться ссылкой).
|
||||
- «**Загрузить с устройства**» (write).
|
||||
- Пункт вложения → «Удалить файл» (write); пусто — «Нет вложений».
|
||||
|
||||
**Тело — Занятость (`Editor/FreeBusy`):**
|
||||
- «**Проверить доступность / Найти время**» → сетка Free/Busy: «Свободен»/«Занят»/«Занят
|
||||
(под вопросом)»/«Нет на месте», навигация «Пред./След. дата», легенда.
|
||||
write (POST в DAV outbox — на forbion НЕ выполняю), `визуал(?)`.
|
||||
|
||||
## Расхождения натив ↔ forbion
|
||||
Полная построчная сверка со счётчиком Ff/Fd — в **`calendar.diff.md`** (итерация 1:
|
||||
Ff_app 51.6% / Fd_app 67.0%). Ключевые открытые (P1→P5):
|
||||
1. **Разворот повторений** в сетке/списке (`eventOccursOnDay` не итерирует RRULE) — P1.
|
||||
2. **EXDATE / RECURRENCE-ID** (правка/удаление «это / это и будущие») — P2.
|
||||
3. **Настройки** (номера недель/выходные/дни рождения/напоминание по умолчанию/…) — P3.
|
||||
4. **Appointments** (расписания встреч) — отсутствуют целиком; применимость на решении лида.
|
||||
5. `toRrule` не сериализует `BYDAY=1MO` — вскроется на развёртке (P1).
|
||||
|
||||
_Закрыто в v0.5.144 (было в старых долгах mail/072, теперь `done`):_ виды Год/Список,
|
||||
корзина, незапланированные задачи, ответы на приглашения (Принять/Может быть/Отклонить).
|
||||
@@ -0,0 +1,151 @@
|
||||
# Diff: Контакты (CardDAV) — итерация 1 (2026-07-13)
|
||||
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]`,
|
||||
Ff/Fd на экран, n-a вне знаменателя. Веб-подписи — из live-прохода forbion (Browser 1/cloude,
|
||||
same-origin iframe 390px, [[f7-mobile-web-iframe-technique]]); натив-подписи — из кода
|
||||
`ContactsScreen.kt`/`ContactDetailSheet.kt`/`ContactsViewModel.kt`/`ContactsRepository.kt` +
|
||||
`core/network/CardDavClient.kt`. Всплывающие меню (mail/087) разобраны поимённо ниже.
|
||||
Fd (дизайн) — отдельным проходом на устройстве после ревью-сборки (как у talk/tasks).
|
||||
|
||||
## Экран 1: Список контактов («Все контакты») — Ff 100.0% (0 open Ф / 3)
|
||||
- [Ф][done] Поиск по списку · натив: поиск по имени/email/телефон (`ContactsUiState.filteredContacts`).
|
||||
- [Ф][done] Открыть карточку (тап) · натив: `openContact` → `ContactDetailSheet`.
|
||||
- [Ф][done] Создать контакт · натив: `F7CreateButton` в шапке → `AddContactDialog`.
|
||||
*(в вебе вызывается из шторки, в нативе из шапки — Д-разница расположения, не Ф.)*
|
||||
- [Д][—] Строка: аватар (фото/инициалы на палитре) + имя 16 + email серым — типографика по
|
||||
живой теме (v0.5.126); детальный Fd на устройстве.
|
||||
|
||||
## Экран 2: Шторка навигации — Ff 18.2% (9 open Ф / 11)
|
||||
Веб-drawer приложения ↔ натив (drawer в модуле ОТСУТСТВУЕТ, есть только плоский список):
|
||||
- [Ф][done] «Создать контакт» · натив: кнопка в шапке (функция есть).
|
||||
- [Ф][done] «Все контакты» · натив: дефолтный список.
|
||||
- [Ф][open] «Импорт контактов» — нет.
|
||||
- [Ф][open] «Организационная структура» — нет (см. Экран 5).
|
||||
- [Ф][open] «Без группы» (фильтр) — нет (групп нет).
|
||||
- [Ф][open] Группы контакта: список групп — нет.
|
||||
- [Ф][open] Группы контакта: «+» создать группу — нет.
|
||||
- [Ф][open] Команды: открыть команду — нет (см. Экран 6).
|
||||
- [Ф][open] Команды: «+» создать команду — нет.
|
||||
- [Ф][open] Команды: «Показать все команды» — нет.
|
||||
- [Ф][open] «Параметры контактов» — нет (см. Экран 7).
|
||||
|
||||
## Экран 3: Карточка контакта (просмотр) — Ff 52.6% (9 open Ф / 19; «Править»/«Удалить» done — P1)
|
||||
Ряд действий (веб) ↔ натив (`ContactDetailContent`):
|
||||
- [Ф][done] Написать письмо (конверт) · натив «Email» (ACTION_SENDTO mailto).
|
||||
*расхождение: уходит во внешний почтовик, не в свой `MailComposeActivity` (план mail/072 п.3).*
|
||||
- [Ф][open] Видеозвонок Talk (камера) — натив: «Позвонить» = системный дайлер (ACTION_DIAL), НЕ Talk.
|
||||
*расхождение: другой канал (план mail/072 п.3).*
|
||||
- [Ф][open] Профиль пользователя (силуэт) — нет.
|
||||
Поля (веб-секции) ↔ натив:
|
||||
- [Ф][done] Email: просмотр + клик→письмо; несколько адресов (`emailLines`).
|
||||
- [Ф][done] Телефон: просмотр + клик→звонок; несколько (`phoneLines`).
|
||||
- [Ф][done] Адрес.
|
||||
- [Ф][done] Сайт (клик→браузер).
|
||||
- [Ф][done] День рождения.
|
||||
- [Ф][done] Организация/должность (subtitle).
|
||||
- [Ф][done] Адресная книга (принадлежность).
|
||||
- [Ф][open] «Социальная сеть» (ссылка на профиль) — нет.
|
||||
- [Ф][open] «ID в объединении облачных хранилищ» (federated cloud ID) — нет.
|
||||
- [Ф][open] «Подробное имя» (Фамилия/Имя/фонетика отдельными полями) — натив показывает только displayName.
|
||||
Меню шапки:
|
||||
- [Ф][done] «✏️ Править» — реализовано (P1, 6b98c5a): кнопка в карточке → форма (Экран 4).
|
||||
- [Ф][done] «Удалить» — реализовано (P1): кнопка с подтверждением, DELETE If-Match.
|
||||
- [Ф][open] ⋯ «Скачать» (.vcf) — нет.
|
||||
- [Ф][open] ⋯ «Копировать контакт» — нет.
|
||||
- [Ф][open] ⋯ «Создать QR-код» — нет.
|
||||
- [Ф][open] ⋯ «Исключить контакт из календаря Дней Рождений» — нет.
|
||||
- [Д][—] Крупный аватар/имя/subtitle, поля-плитки, кнопки Email/Позвонить — по живой теме; Fd на устройстве.
|
||||
|
||||
## Экран 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] «Добавить фото» — нет.
|
||||
- [Ф][~] Тип телефона (Домашний/Рабочий/CELL/…) — типы ПАРСЯТСЯ и сохраняются при правке (P2 шаг1,
|
||||
a8741e9); отдельного селектора типа в форме пока нет — P2 шаг2.
|
||||
- [Ф][~] Тип email — аналогично (парсинг+сохранение есть, селектор — P2 шаг2).
|
||||
- [Ф][open] «О себе» (NOTE) — нет в форме.
|
||||
- [Ф][open] «Часовой пояс» — нет.
|
||||
- [Ф][open] «Группы контакта» (добавить в группу) — нет — P2.
|
||||
- [Ф][open] Выбор адресной книги при создании — нет (пишет в `books.firstOrNull()`).
|
||||
- [Д][—] Веб — полноэкранная форма; натив — компактный диалог. Расхождение композиции (Fd на устройстве).
|
||||
|
||||
## Экран 5: Организационная структура — Ff 0.0% (4 open Ф / 4)
|
||||
Отсутствует в нативе целиком:
|
||||
- [Ф][open] Просмотр графа оргструктуры (карточки сотрудников, связи).
|
||||
- [Ф][open] Селектор «График:».
|
||||
- [Ф][open] Разворачивание/сворачивание узлов (`^N`).
|
||||
- [Ф][open] Переход к карточке из узла.
|
||||
|
||||
## Экран 6: Команда (Circles/Teams) — n-a целиком (0 применимых Ф; n-a 8) — исключён из среднего
|
||||
Решение лида (mail/102): отдельного экрана команд в мобильных Контактах v1 НЕ делаем — Circles это
|
||||
самостоятельный тяжёлый апп (членство/уровни/шары), не контакты. Весь экран `n-a`:
|
||||
- [Ф][n-a] Просмотр команды (название/владелец/участники).
|
||||
- [Ф][n-a] «✏️ Править» команду.
|
||||
- [Ф][n-a] «Копировать ссылку».
|
||||
- [Ф][n-a] «Добавить» участника.
|
||||
- [Ф][n-a] Список участников.
|
||||
- [Ф][n-a] Создать «Папка» — зона `feature/files`.
|
||||
- [Ф][n-a] Создать «Конференции» — зона `feature/talk`.
|
||||
- [Ф][n-a] Создать «Календарь» — зона `feature/calendar`.
|
||||
> Обоснование: Circles = отдельное приложение (мобильно — не в скоупе Контактов v1); три «Создать …»
|
||||
> — кросс-модульные создающие потоки чужих модулей. NB: «команды» ≠ «группы контактов»: группы
|
||||
> (CATEGORIES→чипы) остаются в скоупе (P2).
|
||||
|
||||
## Экран 7: Параметры контактов (модальный шит) — Ff 0.0% (5 open Ф / 5)
|
||||
Отсутствует в нативе:
|
||||
- [Ф][open] «Сортировать по» (Имя/Фамилия/фонетика ×2/Отображаемое имя/Последнее изменение).
|
||||
- [Ф][open] «Импорт контактов».
|
||||
- [Ф][open] Адресные книги: список (Контакты / Недавние / Учётные записи).
|
||||
- [Ф][open] Адресные книги: ⋯ (переименовать/удалить/шаринг книги).
|
||||
- [Ф][open] «Новая адресная книга».
|
||||
|
||||
---
|
||||
## Сводка и приоритеты (прошу приоритизацию у лида)
|
||||
Ff_app **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`, выбор адресной книги, поля должность/адрес/
|
||||
даты/о-себе/часовой пояс, **типы TEL/EMAIL**. Требует href+ETag в `DavContact`/`ContactEntity` —
|
||||
core-правка `CardDavClient`+Room, лид дал добро в mail/072 (условия: не менять list/create,
|
||||
юниты vCard round-trip, гейт `:app:compileReleaseKotlin`, Room-миграция).
|
||||
2. **Группы контакта** (Экран 2 + поле формы + фильтр «Без группы»): парс CATEGORIES → чипы-фильтр.
|
||||
3. **Шторка навигации** (Экран 2): импорт, оргструктура, группы, команды, параметры — как единый drawer.
|
||||
4. **Привязка к каналам** (Экран 3): «Email» → свой композер, «Позвонить» → Talk-звонок
|
||||
(кросс-модуль feature/mail, feature/talk — согласую письмами, лид в копию).
|
||||
5. **Параметры** (Экран 7): сортировка, импорт, управление адресными книгами.
|
||||
Прочее: соц.сеть/cloud-ID/QR/скачать/копировать в карточке; оргструктура-граф; экран команд (см. n-a).
|
||||
|
||||
## Вопросы к лиду (b-dev-mobile) — РЕШЕНО (mail/102)
|
||||
- ✅ Порядок P1→P2→P4→P3→P5 — утверждён.
|
||||
- ✅ **Команды (Circles/Teams)** (Экран 6) — `n-a` целиком (учтено выше).
|
||||
- ⏳ **Оргструктура** (Экран 5) — лид пока не высказался; держу `open` (не занижаю знаменатель
|
||||
односторонним n-a). Аналогична Circles по «тяжести» — вероятный кандидат в n-a, жду слова.
|
||||
- Fd: гоню отдельным проходом на устройстве после ревью-сборки — сборка/версия по гейту (084) за тобой.
|
||||
|
||||
## Live-запись (mail/085, ограждения)
|
||||
Read-only снят: edit/delete-флоу закрою live на forbion CRUDʼом СВОИХ `bmob-*` контактов
|
||||
(создать→править→удалить свой же), не трогая реальные данные `cloude`. Пока правка edit/delete не
|
||||
написана — веду из кода + юнит-тесты на vCard round-trip (обязательны по mail/072).
|
||||
@@ -0,0 +1,143 @@
|
||||
# Карта: Контакты (forbion, мобильная веб-версия)
|
||||
|
||||
Снято 2026-07-12 наблюдением мобильной вёрстки forbion (вьюпорт 390px, аккаунт `cloude`,
|
||||
строго read-only по mail/073). Пишущие элементы («Создать контакт», «Импорт», «+» у групп/команд,
|
||||
«Сохранить», «Удалить», «⋯» адресных книг) НЕ нажимались — их наличие и подписи сняты с экрана/DOM.
|
||||
|
||||
## Вход
|
||||
Нижняя панель шелла → кнопка навигации (стрелка `<`, подпись раздела «Контакты») открывает
|
||||
шторку приложения; либо прямой URL `/apps/contacts/`. Приложение открывается сразу списком
|
||||
«Все контакты» (первый контакт автовыбран на широком экране; в мобильной вёрстке — только список).
|
||||
|
||||
Нижняя панель шелла (общая, зона лида, для ориентира): `<`/`>` — открыть/закрыть шторку
|
||||
навигации приложения · конверт → Почта · «карточки» → Задачи · камера → Конференции ·
|
||||
`≡` → шторка приложений. Панель плавающая поверх контента.
|
||||
|
||||
## Экран: Список контактов («Все контакты»)
|
||||
**Шапка:** только поле поиска (pill на всю ширину). Заголовка экрана и кнопки «создать» в шапке НЕТ
|
||||
(создание — в шторке навигации).
|
||||
**Тело:** плоский список: аватар (фото или инициалы на цветном круге) + имя (16, тёмный) +
|
||||
email (второй строкой, серый). Разделители-линии. Сортировка — по отображаемому имени
|
||||
(настраивается в «Параметрах контактов»).
|
||||
**Действия:**
|
||||
- Ввод в поиск → фильтрация списка на лету (имя/email); справа в поле появляется `×` — очистить.
|
||||
- Тап по контакту → «Экран: Карточка контакта».
|
||||
- Тап `<` на нижней панели → «Экран: Шторка навигации».
|
||||
|
||||
## Экран: Шторка навигации (drawer слева, поверх списка)
|
||||
**Верх:**
|
||||
- «Создать контакт» (зелёная, слева иконка) + отдельный сегмент `+` справа — (не нажималось,
|
||||
read-only; по аналогии с десктопом `+` = быстрое создание).
|
||||
- В контексте команды над ней появляется вторая зелёная кнопка «Импорт контактов» + `+`.
|
||||
**Пункты:**
|
||||
- «Все контакты» [бейдж: 105] → список всех.
|
||||
- «Организационная структура» → «Экран: Организационная структура».
|
||||
- «Без группы» [105] → список контактов без групп (у `cloude` все 105 без групп).
|
||||
- Секция «Группы контакта» [`+`] — пусто (групп нет); `+` = создать группу (не нажималось).
|
||||
- Секция «Команды» [`+`]: «Тест», «admin», «all», «Администраторы поддержки», «Маркетинг»,
|
||||
«Показать все команды». У активной команды справа `⋯` (меню; не раскрывалось).
|
||||
Тап по команде → «Экран: Команда».
|
||||
- Кнопка «Параметры контактов» (внизу шторки) → «Экран: Параметры контактов».
|
||||
**Закрытие:** тап по контенту правее шторки (или `>` на нижней панели — кнопка-стрелка меняет
|
||||
направление). NB: кнопка панели при открытой шторке имеет aria «Закрыть навигацию».
|
||||
|
||||
## Экран: Карточка контакта (просмотр)
|
||||
**Шапка:** `←` (назад к списку) слева сверху.
|
||||
**Тело (по центру):** большой аватар; имя крупно; подпись — должность/организация
|
||||
(например «ооо--форбион», «Ген.дир»); ряд круглых кнопок-действий; секции полей.
|
||||
**Кнопки-действия (круглые, состав зависит от контакта):**
|
||||
- видеокамера → звонок/беседа Talk с пользователем (у системных контактов-пользователей),
|
||||
- силуэт → профиль пользователя на сервере,
|
||||
- конверт → написать письмо.
|
||||
У обычного (не системного) контакта была только кнопка-конверт.
|
||||
**Секции полей (просмотр; показываются только заполненные):**
|
||||
- «Эл. почта» (значение в рамке-поле),
|
||||
- «Социальная сеть» (ссылка на профиль `…/index.php/u/<user>`),
|
||||
- «Подробное имя» (Имя, Фамилия отдельными строками),
|
||||
- «ID в объединении облачных хранилищ» (federated cloud ID `user@forbion.f7cloud.ru`),
|
||||
- «Адрес» (подтип, напр. «Город»),
|
||||
- «Личные даты» (день рождения),
|
||||
- «Адресная книга» (какой книге принадлежит: «Учётные записи» / «Контакты»).
|
||||
**Действия:**
|
||||
- «✏️ Править» (рядом с именем; ЕСТЬ только у контактов из редактируемых книг, у системных
|
||||
из «Учётных записей» отсутствует) → «Экран: Правка контакта».
|
||||
- `⋯` (справа от имени) → меню:
|
||||
- системный контакт («Учётные записи»): «Скачать», «Копировать контакт», «Создать QR-код»;
|
||||
- обычный контакт: те же + «Исключить контакт из календаря Дней Рождений», «Удалить»
|
||||
(не нажималось).
|
||||
|
||||
## Экран: Правка контакта (после «Править»)
|
||||
Заголовок вверху — «Создание нового контакта» (похоже, баг вебки: открыт существующий контакт,
|
||||
все данные на месте).
|
||||
**Тело (форма):**
|
||||
- Большой аватар + зелёная кнопка «Добавить фото»;
|
||||
- Поле имени; два поля должности (placeholder «Должност…» + значение, напр. «Ген.дир»);
|
||||
- Кнопки: «✓ Сохранить…», `⋯`;
|
||||
- «Телефон»: строка типа (дропдаун «Домашний» с `×` и `∨`) + поле значения;
|
||||
- «Эл. почта»: тип + значение (та же пара);
|
||||
- «Адрес»: подтип «Город» + поле;
|
||||
- «Личные даты»: поле с датой (дата-пикер);
|
||||
- «О себе»: многострочное поле;
|
||||
- «Часовой пояс»: дропдаун;
|
||||
- «Группы контакта»: дропдаун «Добавить контакт в гру…».
|
||||
**Выход:** `←` — назад без сохранения (проверено, изменений не делалось).
|
||||
|
||||
## Экран: Организационная структура
|
||||
**Шапка:** «График:» + селектор.
|
||||
**Тело:** вертикальное дерево-граф: карточки сотрудников (аватар-инициал, имя зелёным,
|
||||
счётчики «Управляет: N» / «Наблюдает: N»), связи линиями, узлы сворачиваются бейджем `^N`
|
||||
на ребре. Пример: admin → g.kuts@proorder.ru.
|
||||
|
||||
## Экран: Команда (например «Тест»)
|
||||
**Тело:**
|
||||
- Аватар-инициал команды, название, «Владелец» + чип с аватаром (`you`);
|
||||
- Кнопки: «✏️ Править» (зелёная), «Копировать ссылку»;
|
||||
- Секция «Создавать»: кнопки-пилюли «Папка», «Конференции», «Календарь» — создание общих
|
||||
ресурсов команды (не нажималось);
|
||||
- Секция «Члены»: «Добавить» (не нажималось) + список участников (аватар, имя, роль «Владелец»).
|
||||
- «Показать все команды» в шторке ведёт на список команд (у `cloude` контент совпал с экраном
|
||||
команды — все команды и так в шторке).
|
||||
|
||||
## Экран: Параметры контактов (модальный шит, `×` для закрытия)
|
||||
- Секция «Общие»: «Сортировать контракты по» — дропдаун: Имя / Фамилия /
|
||||
Имя (фонетическое написание) / Фамилия (фонетическое написание) / Отображаемое имя (текущее) /
|
||||
Последнее изменение;
|
||||
- Кнопка «Импорт контактов» (не нажималось);
|
||||
- Секция «Адресные книги», у каждой `⋯` (не раскрывалось):
|
||||
- «Контакты» — 1 контакт, 0 групп;
|
||||
- «Недавние контакты» — 0 контактов, 0 групп;
|
||||
- «Учётные записи» — «Системная адресная книга, в которой хранятся все учетные записи»,
|
||||
104 контакта, 0 групп;
|
||||
- Кнопка «Новая адресная книга» (не нажималось).
|
||||
|
||||
## Пустые состояния / ошибки
|
||||
- Поиск без совпадений: список пустеет (отдельной заглушки на 390px не замечено).
|
||||
- Секция «Группы контакта» без групп — просто пустая (без заглушки).
|
||||
- Состояния ошибок сети не воспроизводились (read-only, боевой сервер).
|
||||
|
||||
## Расхождения с нативным клиентом (бэклог b-mob-contacts)
|
||||
- расхождение: **редактирование/удаление контакта** — в вебе «Править» (полная форма: фото, имя,
|
||||
должность, телефон/почта С ТИПАМИ, адрес, даты, о себе, часовой пояс, группы) и «Удалить» в `⋯`;
|
||||
в нативе ничего этого нет (план mail/072 п.1).
|
||||
- расхождение: **типы полей** (Домашний/…) у телефона/почты — натив не парсит TYPE.
|
||||
- расхождение: **группы контакта** (секция в шторке, поле в форме) — в нативе нет (план п.2).
|
||||
- расхождение: **шторка навигации** (Все контакты/Орг.структура/Без группы/Группы/Команды/
|
||||
Параметры) — в нативе только плоский список с поиском.
|
||||
- расхождение: **организационная структура** (граф) — в нативе нет.
|
||||
- расхождение: **команды** (экран команды, создание ресурсов, члены) — в нативе нет.
|
||||
- расхождение: **параметры** (сортировка, импорт, управление адресными книгами, новая книга) —
|
||||
в нативе нет; создание пишет в первую книгу без выбора.
|
||||
- расхождение: **карточка**: соц.сеть, federated cloud ID, «Исключить из календаря ДР», QR-код,
|
||||
«Скачать», «Копировать контакт» — в нативе нет; зато в нативе есть прямые «Email»/«Позвонить»
|
||||
(системные интенты) — в вебе звонок идёт через Talk-кнопку у системных контактов.
|
||||
- расхождение: **меню `⋯` карточки различается по книге** (системная без «Удалить») — нативу
|
||||
при реализации правки учесть read-only книги.
|
||||
- наблюдение: заголовок формы правки в вебе — «Создание нового контакта» (баг вебки, в нативе
|
||||
не повторять).
|
||||
|
||||
## Примечания к методике
|
||||
Снималось через same-origin iframe 390px внутри Chrome (окно 2560px нельзя ужать) — media queries
|
||||
отрабатывают по ширине iframe, тема отдаёт мобильную вёрстку (`header__mobile`,
|
||||
`header__mobile-bottom`, `app-content-wrapper--no-split`). Артефакт: плавающая нижняя панель в
|
||||
iframe не отрисовывается (visibility:hidden не снимается темой), состав панели снят из DOM;
|
||||
на реальном телефоне панель видна.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Diff: Карточки (Deck) — итерация 1 (2026-07-13)
|
||||
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`/
|
||||
`DeckCardSheet`/`DeckViewModel`, внутренний фронт-API `/apps/deck/api/v1.0`). Метод mail/083:
|
||||
каждый пункт `[Ф|Д]`·`[open|done|n-a]`, Ff/Fd на экран. Дизайн (Fd) — на устройстве след. проход;
|
||||
здесь Ф-функционал из кода + структурные Д, где очевидно. Всплывающие меню (mail/087) раскрыты
|
||||
поимённо и сверены построчно.
|
||||
|
||||
> Контекст доступа: запись на forbion под `cloude` разрешена с ограждениями (mail/085 — только
|
||||
> свои объекты `bmob-*`, уборка). Пишущие пункты ниже, помеченные `[open]` из-за «нет в нативе»,
|
||||
> закрываются реализацией; «пишущее не проверено на живом» — сниму прогоном CRUD на доске
|
||||
> `bmob-deck` после реализации.
|
||||
|
||||
---
|
||||
|
||||
## Экран 1: Навигация приложения (веб-дровер) — Ff 16.7% (10 open Ф / 12)
|
||||
В нативе отдельного дровера приложения нет — вход = сразу список досок. Пункты веб-навигации:
|
||||
- [Ф][open] «**Ожидающие выполнения**» (обзор по срокам) — экрана нет.
|
||||
- [Ф][done] «**Все доски**» — натив показывает список досок (это дефолт-экран).
|
||||
- [Ф][done] Открыть доску (тап пункта) — есть.
|
||||
- [Ф][open] «**Добавить доску**» (инлайн: цвет + имя) — создания доски нет.
|
||||
- [Ф][open] «**Импортировать доску**» — нет.
|
||||
- [Ф][open] «**Настройки Deck**» — экрана настроек нет.
|
||||
|
||||
Всплывающее меню доски (⋯ на пункте, `AppNavigationBoard`) — веб ↔ натив (в нативе меню доски НЕТ):
|
||||
- [Ф][open] «**Сведения о доске**» (сайдбар доски) — нет.
|
||||
- [Ф][open] «**Редактировать доску**» (имя/цвет) — нет.
|
||||
- [Ф][open] «**Клонировать доску**» — нет.
|
||||
- [Ф][open] «**Архивировать/Разархивировать доску**» — нет (архивные доски просто скрыты).
|
||||
- [Ф][open] «**Экспортировать доску**» — нет.
|
||||
- [Ф][open] «**Напоминания о сроках**» (Все/Назначенные/Выкл.) — нет.
|
||||
|
||||
## Экран 2: Все доски (список) — Ff 50.0% (1 open Ф / 2)
|
||||
- [Ф][done] Открыть доску (тап строки) — есть.
|
||||
- [Ф][open] Поиск/фильтр списка досок (`navFilter`) — нет.
|
||||
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски
|
||||
(`сверено:` 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 дней/Позже/
|
||||
Без назначенной даты) — экрана в нативе нет совсем.
|
||||
- [Ф][open] Открыть карточку из обзора — нет (нет обзора).
|
||||
- [Д][open] Композиция: 6 секций-капшенов в столбец, карточки внутри — отсутствует.
|
||||
|
||||
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 72.0% (7 open Ф / 25)
|
||||
**Шапка доски (`Controls`):**
|
||||
- [Ф][done] «**Добавить список**» — натив «+ Колонка» (`createStack`).
|
||||
- [Ф][open] «**Применить фильтр**» (по метке / назначенному / статусу) — фильтров нет.
|
||||
- [Ф][open] «**Режимы просмотра**» (Показывать архивные карточки / компактный режим / обложки) — нет.
|
||||
- [Ф][open] «**Открыть подробности**» (сайдбар доски) — нет.
|
||||
- [Ф][done] «**Добавить карточку**» — натив добавляет карточку из шапки колонки.
|
||||
**Всплывающее меню колонки (⋯, `Stack`)** — в нативе меню колонки НЕТ:
|
||||
- [Ф][open] «**Архивировать все карточки**» — нет.
|
||||
- [Ф][open] «**Разархивировать все карточки**» — нет.
|
||||
- [Ф][done] «**Удалить список**» — реализовано (меню «⋯» → подтверждение `DeckConfirmDialog`).
|
||||
- [Ф][done] «**Изменить заголовок списка / Переименовать**» — реализовано (меню «⋯» → диалог с
|
||||
префиллом, `renameStack`).
|
||||
- [Ф][done] «**Добавить карточку**» в колонку — есть (инлайн-поле «Имя карточки»).
|
||||
**Карточка на доске (`CardItem` + бейджи):**
|
||||
- [Ф][done] Открыть карточку (тап) — открывает лист-деталь.
|
||||
- [Ф][done] Правка заголовка — есть (в листе-детали; веб правит инлайн — Д-нюанс).
|
||||
- [Ф][done] Бейдж срока (`DueDate`) — есть.
|
||||
- [Ф][done] Бейдж «**чек-пунктов**» «☑ x/total» — реализовано (`deckChecklistCounts`, парсинг
|
||||
чекбоксов описания regex как веб).
|
||||
- [Ф][done] Бейдж «**комментариев**» «💬 N» — реализовано (`commentsCount`); индикатор M
|
||||
непрочитанных — Д-нюанс на след. проход.
|
||||
- [Ф][done] Аватары назначенных — есть (отображение).
|
||||
**Всплывающее меню карточки (⋯ / long-press, `CardMenuEntries`)** — в нативе overflow-меню НЕТ
|
||||
(тап открывает лист; часть действий — кнопками листа):
|
||||
- [Ф][done] «**Сведения о карточке**» — тап открывает лист-деталь.
|
||||
- [Ф][done] «**Изменить заголовок**» — в листе.
|
||||
- [Ф][done] «**Назначить мне / Снять назначение с меня**» — реализовано (кнопка в листе-детали,
|
||||
`toggleAssignSelf`; назначение ПРОИЗВОЛЬНОГО пользователя — отдельный пункт, экран 5).
|
||||
- [Ф][done] «**Отметить выполненной / невыполненной**» — чекбокс done есть.
|
||||
- [Ф][open] «**Переместить/копировать карточку**» — есть перенос между колонками ОДНОЙ доски
|
||||
(чипы), но нет копирования и переноса на ДРУГУЮ доску.
|
||||
- [Ф][done] «**Архивировать/Разархивировать карточку**» — кнопка «В архив» есть.
|
||||
- [Ф][done] «**Удалить карточку**» — кнопка «Удалить» есть.
|
||||
- [Ф][open] «**Отменить удаление**» (Undo-тост) — натив удаляет сразу, без отмены.
|
||||
- [Ф][done] Перенос карточки между колонками (в вебе drag-n-drop) — натив реализует переносом
|
||||
через чипы «Колонка» (Д-нюанс: без DnD-жеста).
|
||||
|
||||
**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) — есть.
|
||||
- [Ф][done] Срок: «**быстрые опции**» — реализованы чипы Сегодня/Завтра/Выходные/Неделя
|
||||
(`deckQuickDueIso`). *(«Позже сегодня» с часом — Д-нюанс, натив ставит дату; на след. проход.)*
|
||||
- [Ф][done] Срок: «**Убрать срок**» — есть («Сбросить»).
|
||||
- [Ф][done] Срок: «Отметить выполненной» из блока срока — есть (done).
|
||||
- [Ф][done] Описание: редактировать — есть.
|
||||
- [Ф][open] Описание: «**Просмотр/Справка по форматированию**» (rich-preview) — натив простое поле.
|
||||
- [Ф][open] Описание: «**Добавить вложение**» из описания — нет.
|
||||
- [Ф][n-a] Блок «Проекты» (`NcCollectionList`) — зависит от NC-аппа проектов, вне ядра клиента.
|
||||
**Вложения (Attachments) — таба нет в нативе:**
|
||||
- [Ф][open] «**Загрузить новые файлы**» — нет.
|
||||
- [Ф][open] «**Поделиться из Файлов**» — нет.
|
||||
- [Ф][open] Действия вложения (Скачать/Показать в Файлах/Удалить/Восстановить) — нет.
|
||||
**Комментарии (Comments) — табы нет:**
|
||||
- [Ф][open] «**Добавить комментарий**» (форма + Сохранить) — нет.
|
||||
- [Ф][open] Читать ленту комментариев — нет.
|
||||
**Активность (Activity):**
|
||||
- [Ф][open] Просмотр активности карточки — нет.
|
||||
**Шапка листа:**
|
||||
- [Ф][done] Правка заголовка карточки — есть.
|
||||
- [Ф][n-a] «Открыть в боковом / большом виде» — на мобильном единый лист, не применимо.
|
||||
|
||||
## Экран 6: Сайдбар доски (Общий доступ/Метки/Удалённые/Активность) — Ff 0.0% (10 open Ф / 10)
|
||||
Всего экрана в нативе нет:
|
||||
- [Ф][open] «**Общий доступ**»: пошарить доску, права (Can edit/share/manage), «Передать доску», удалить доступ (4 пункта).
|
||||
- [Ф][open] «**Метки**»: добавить/редактировать/удалить метку доски (3 пункта).
|
||||
- [Ф][open] «**Удалённые элементы**»: восстановить удалённые списки/карточки (2 пункта).
|
||||
- [Ф][open] «**Активность**» доски (1 пункт).
|
||||
|
||||
## Экран 7: Диалог «Переместить/копировать карточку» — Ff 25.0% (3 open Ф / 4)
|
||||
- [Ф][done] Переместить в колонку той же доски — есть (чипы в листе; Д: без диалога).
|
||||
- [Ф][open] «**Выбрать доску**» (перенос на другую доску) — нет.
|
||||
- [Ф][open] «**Копировать карточку**» — нет.
|
||||
- [Ф][open] Диалог с селекторами «Доска/Список» — нет (переноса на др. доску нет).
|
||||
|
||||
## Экран 8: Настройки Deck — Ff 0.0% (3 open Ф / 3)
|
||||
Экрана настроек в нативе нет:
|
||||
- [Ф][open] «**Использовать увеличенный вид карточки**» — нет.
|
||||
- [Ф][open] «**Показывать бейдж ID карточки**» — нет.
|
||||
- [Ф][open] «**Показывать доски в календаре/задачах**» — нет.
|
||||
- [Ф][n-a] «Настройки администратора» (ограничить создание досок группами) — админ-функция, не клиентская.
|
||||
|
||||
---
|
||||
|
||||
## Сводка по экранам
|
||||
| Экран | Ff | open Ф / всего |
|
||||
|---|---|---|
|
||||
| 1. Навигация + меню доски | 16.7% | 10/12 |
|
||||
| 2. Все доски (список) | 50.0% | 1/2 |
|
||||
| 3. Обзор (по срокам) | 0.0% | 2/2 |
|
||||
| 4. Доска (Controls/Колонка/Карточка) | 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 (среднее)** | **25.3%** | **47/76** |
|
||||
|
||||
## Приоритеты (черновик, жду визы лида — mail/071 план 1→2→3)
|
||||
- **P1 (в своём модуле, наибольший охват):** меню карточки (Назначить мне/Переместить-копировать/
|
||||
Undo при удалении); бейджи чек-пунктов и комментариев; меню колонки (Удалить список/Архивировать
|
||||
все/Переименовать); «быстрые опции» срока.
|
||||
- **P2:** сайдбар карточки — Комментарии и Вложения (Вложения зависят от медиа-слоя b-mob-files,
|
||||
mail/083 — согласую точечно); назначение исполнителей (write через `/cards/{id}/assign`).
|
||||
- **P3:** обзор «Ожидающие выполнения» (дашборд по срокам); фильтры доски; CRUD досок
|
||||
(создать/править/архив/клон/экспорт-импорт); сайдбар доски (шаринг/метки/удалённые); настройки.
|
||||
- **n-a (в финал с обоснованием):** проекты-коллекции, «боковой/большой вид» карточки,
|
||||
админ-настройки, drag-n-drop-жест (заменён чипами — функц-эквивалент есть).
|
||||
|
||||
## Медиа-инвентарь (mail/081 §3 / mail/083 B)
|
||||
В «Карточках» собственных медиа-просмотрщиков нет; вложения карточки в вебе открываются общим
|
||||
NC-viewer. В нативе вкладки «Вложения» пока нет вовсе — когда появится (P2), просмотр
|
||||
переиспользую через общий вход `feature/files` (Coil/Media3/PdfRenderer), НЕ плодя своё. Точки
|
||||
интеграции согласую с b-mob-files, когда дойду до вложений.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Карта: Карточки (Deck)
|
||||
|
||||
Приложение «Карточки» (`/apps/deck/`) на forbion.f7cloud.ru. Структура кнопок/меню/переходов
|
||||
снята **из кода** (метод mail/078): апстрим `nextcloud/deck` **v1.16.4** (`src/**/*.vue` — точная
|
||||
версия с forbion, канон README) + мобильная тема forbion
|
||||
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-deck.css`,
|
||||
`css/pages/app-deck/_app-deck.css`). Живой мобильный web (Browser 1, `cloude`, mail/079) — только
|
||||
для визуальной сверки «как выглядит/порядок»; **строго read-only** (mail/073): ничего не
|
||||
создаётся/не меняется/не удаляется. Приватность: реальные названия досок/карточек не
|
||||
фиксируются — только структура UI и дерево переходов.
|
||||
|
||||
> Статус визуальной сверки на живом forbion: **выполнена 2026-07-12** (Browser 1, `cloude`,
|
||||
> мобильный вьюпорт через same-origin iframe 390px, read-only — ни одной пишущей операции).
|
||||
> Реальные русские подписи forbion внесены ниже; расхождения кода ↔ живой темы помечены
|
||||
> `сверено:`. Пройдены: Обзор, Все доски, доска (Controls+стеки+карточки), сайдбар карточки.
|
||||
|
||||
## Компоненты-источники (nextcloud/deck v1.16.4)
|
||||
Навигация: `navigation/AppNavigation.vue` (+ `AppNavigationAddBoard`, `AppNavigationBoard`,
|
||||
`AppNavigationBoardCategory`, `AppNavigationImportBoard`, `BoardCloneModal`, `BoardExportModal`).
|
||||
Список досок: `boards/Boards.vue`, `boards/BoardItem.vue`. Обзор: `overview/Overview.vue`
|
||||
(+ `dashboard/Card.vue`, `views/DashboardToday|Tomorrow|Upcoming.vue`). Доска: `board/Board.vue`,
|
||||
`board/Stack.vue`, `Controls.vue`. Карточка на доске: `cards/CardItem.vue`, `cards/CardMenu.vue`,
|
||||
`cards/CardMenuEntries.vue`, `cards/CardBadges.vue`, `cards/AvatarList.vue`, `cards/badges/DueDate.vue`,
|
||||
`cards/badges/CardId.vue`. Сайдбар карточки: `card/CardSidebar.vue` + табы `CardSidebarTabDetails`
|
||||
(→ `TagSelector`, `AssignmentSelector`, `DueDateSelector`, `Description`, `AttachmentList`),
|
||||
`CardSidebarTabAttachments`, `CardSidebarTabComments` (+ `CommentForm`, `CommentItem`),
|
||||
`CardSidebarTabActivity`. Сайдбар доски: `board/BoardSidebar.vue` + `SharingTabSidebar`,
|
||||
`TagsTabSidebar`, `DeletedTabSidebar`, `TimelineTabSidebar`. Диалоги: `CardMoveDialog.vue`,
|
||||
`CardCreateDialog.vue`, `BoardSelector.vue`, `CardSelector.vue`. Настройки: `DeckAppSettings.vue`
|
||||
(+ `KeyboardShortcuts.vue`).
|
||||
|
||||
## Вход
|
||||
Шторка приложений (по «трём полоскам») → «Карточки», или прямой URL `/apps/deck/`. Оболочку
|
||||
(нижняя панель, шторка, бургер) описывает `shell.md` (зона лида) — здесь только специфика
|
||||
приложения. На мобильном (узкий вьюпорт) тема (`_mobile-deck.css`) раскладывает **доску
|
||||
колонками по вертикали** (`.smooth-dnd-container.horizontal { flex-direction: column }`),
|
||||
навигацию убирает под бургер/шторку навигации, а сайдбары/модалки/настройки раскрывает **на
|
||||
весь экран** (`modal-container height:100%`, `modal-header` скрыт).
|
||||
|
||||
## Экран: Навигация приложения (левая шторка — `AppNavigation`)
|
||||
На мобильном — за «бургером»/шторкой навигации приложения (не нижняя панель оболочки).
|
||||
**Тело (пункты по порядку, `сверено:` подписи с forbion):**
|
||||
- «**Ожидающие выполнения**» (Upcoming cards, `to="/"`) → экран «Обзор» (дашборд по срокам).
|
||||
`сверено:` на forbion пункт назван «Ожидающие выполнения», это и есть лендинг приложения.
|
||||
- «**Все доски**» (All boards, `to="/board"`, иконка deck) → список досок.
|
||||
- Список **личных досок** (каждая — `AppNavigationBoard`, см. ниже), сгруппировано по категориям
|
||||
(`AppNavigationBoardCategory`).
|
||||
- «**Добавить доску**» (Add board, иконка +) → инлайн-создание: цветной bullet (палитра-пикер) +
|
||||
поле «**Имя доски**» (Board name) + кнопки «Отмена правки»/«Сохранить доску». *(write — не жму.)*
|
||||
- «**Импортировать доску**» (Import board, иконка upload) → диалог импорта (файл). *(write — не жму.)*
|
||||
- Внизу: «**Настройки Deck**» (Deck settings, шестерёнка) → модалка настроек.
|
||||
> `сверено:` в мобильной навигации forbion пунктов «**Архивные доски**» (`/board/archived`) и
|
||||
> «**Доски, доступные вам**» (`/board/shared`) НЕ видно — в коде они есть (`AppNavigation`), но
|
||||
> на узком вьюпорте тема их не выводит (или показывает только при непустом списке). Для нативного
|
||||
> клиента это скорее плюс: паритет по видимой мобильной навигации не требует этих двух видов.
|
||||
|
||||
### Меню доски (⋯ на пункте доски — `AppNavigationBoard` NcActions)
|
||||
Порядок пунктов из кода:
|
||||
- «**Сведения о доске**» (Board details, icon-info) → открывает сайдбар доски (см. ниже).
|
||||
- «**Редактировать доску**» (Edit board, icon-rename; если canManage и не архивная) →
|
||||
инлайн-правка названия/цвета.
|
||||
- «**Клонировать доску**» (Clone board; если canCreate) → модалка `BoardCloneModal`.
|
||||
- «**Разархивировать доску**» / «**Архивировать доску**» (Unarchive/Archive board) — тумблер.
|
||||
- «**Экспортировать доску**» (Export board, icon-download) → модалка `BoardExportModal`.
|
||||
- «**Вкл./выкл. напоминания о сроках**» → подменю «**Напоминания о сроках**» (Due date reminders):
|
||||
«Все карточки» (All cards) / «Назначенные карточки» (Assigned cards) / «Выкл.» (Off).
|
||||
*(все write — не выполняю.)*
|
||||
|
||||
## Экран: Все доски (список — `Boards.vue`)
|
||||
**Тело:** таблица-список досок. Тема (`_app-deck.css`, `_mobile-deck.css`) делает каждую строку
|
||||
карточкой (border, radius 8px). Колонки-заголовки (`board-list-header-row`):
|
||||
- «**Имя доски**» (Board name) — bullet цвета доски + название (тап по строке → открыть доску).
|
||||
- «**Участники**» (Members) — аватары ACL (`AvatarList`; если >5 — «+N» иконкой more).
|
||||
- Поле фильтра списка `navFilter` (быстрый поиск по названию доски) — `визуал(?)` (положение
|
||||
на мобильной вёрстке).
|
||||
**Пустое состояние:** стандартный `NcEmptyContent` (тема подставляет deck-иконку).
|
||||
|
||||
## Экран: Доска (`Board.vue` + `Controls.vue`)
|
||||
**Шапка (`Controls`)** — `сверено:` на мобильном forbion в шапке доски 4 контрола (иконками):
|
||||
- «**Добавить список**» (Add list) — инлайн-поле «**Имя списка**» (List name) + подтверждение.
|
||||
*(write — не жму.)*
|
||||
- «**Применить фильтр**» (иконка-воронка; активная = primary). Раскрывает поповер фильтров:
|
||||
- «**Фильтр по метке**» (Filter by tag) — чекбоксы по меткам доски.
|
||||
- «**Фильтр по назначенному**» (Filter by assigned user) — «Без назначения» (Unassigned) +
|
||||
список пользователей доски.
|
||||
- «**Фильтр по статусу**» (Filter by status) — «Открытые и выполненные» / «Открытые» / «Выполненные».
|
||||
- «**Режимы просмотра**» (View modes, иконка-сетка) — `сверено:` поповер с переключателями:
|
||||
«**Показывать архивные карточки**», «**Выбор компактного или обычного режима просмотра**»,
|
||||
«**Показывать обложки карточек**». *(в карте кода этого блока не было — добавлено по сверке.)*
|
||||
- «**Открыть подробности**» (Open details, ⋯) → сайдбар доски (Общий доступ/Метки/Удалённые/Активность).
|
||||
- «**Добавить карточку**» (Add card) в коде тоже есть в Controls — на мобильном быстрый ввод идёт
|
||||
из шапки колонки (см. Stack). *(write — не жму.)*
|
||||
**Тело:** колонки-списки (`Stack`) по вертикали (мобильная тема), в каждой — карточки.
|
||||
**Состояния:** «Загрузка доски» (Loading board) / «Доска не найдена» (Board not found) /
|
||||
пусто «**Нет доступных списков**» (No lists available) + «Создать новый список, чтобы добавлять
|
||||
карточки» + поле «Имя списка».
|
||||
|
||||
### Колонка-список (`Stack.vue`)
|
||||
**Шапка колонки:**
|
||||
- Заголовок списка — тап (canManage) → инлайн-правка «**Изменить заголовок списка**» (Edit list title).
|
||||
- Меню колонки (⋯, `NcActions`): «**Архивировать все карточки**» (Archive all cards) /
|
||||
«**Разархивировать все карточки**» (Unarchive all cards) → подтверждение
|
||||
(«Архивировать все карточки в этом списке» + Архивировать/Отмена); «**Удалить список**»
|
||||
(Delete list). *(все write — не выполняю.)*
|
||||
- «**Добавить карточку**» (Add card) → инлайн-поле «**Имя карточки**» (Card name). *(write — не жму.)*
|
||||
**Тело:** карточки (`CardItem`), drag-n-drop переносом между колонками (мышью/тачем).
|
||||
|
||||
### Карточка на доске (`CardItem` + `CardBadges`)
|
||||
- Тап по карточке → сайдбар/деталь карточки.
|
||||
- Заголовок (тап при canEdit / клавиша `E` → инлайн-правка «Edit card title»).
|
||||
- Бейджи (`CardBadges`): срок (`DueDate` — цвет по просрочке, «выполнено»→галочка), счётчик
|
||||
«**чек-пунктов**» (Todo items), счётчик «**комментариев**» (N comments, M unread), аватары
|
||||
назначенных (`AvatarList`), ID карточки (если включён бейдж ID в настройках).
|
||||
- Меню карточки (⋯ / long-press, `CardMenuEntries`): «**Сведения о карточке**» (Card details) →
|
||||
сайдбар; «**Изменить заголовок**» (Edit title); «**Назначить мне**» / «**Снять назначение с
|
||||
меня**» (Assign to me / Unassign myself); «**Отметить выполненной**» / «**Отметить
|
||||
невыполненной**» (Mark as done / not done); «**Переместить/копировать карточку**» (Move/copy
|
||||
card) → диалог; «**Архивировать**» / «**Разархивировать карточку**» (Archive/Unarchive card);
|
||||
«**Удалить карточку**» (Delete card) — с тостом «Карточка удалена» + «Отменить» (undo).
|
||||
*(все write — не выполняю.)*
|
||||
- Клавиши на карточке: `E` правка заголовка, `S` назначить себя, archive/done по хоткею (см. настройки).
|
||||
|
||||
## Экран: Обзор / Предстоящие карточки (`Overview.vue`)
|
||||
Дашборд карточек по срокам (`to="/"`, заголовок экрана «**Ожидающие выполнения**» + кнопка «+»),
|
||||
колонки-секции (мобильная тема ставит их в столбец). `сверено:` реальные подписи forbion:
|
||||
«**Просроченные**», «**Сегодня**», «**Завтра**», «**Ближайшие 7 дней**», «**Позже**»,
|
||||
«**Без назначенной даты**». Каждая секция — список `dashboard/Card.vue` (карточки с меткой,
|
||||
иконкой описания и ⋯). Состояние загрузки: «Загрузка отфильтрованного вида».
|
||||
|
||||
## Сайдбар карточки (`CardSidebar` — на мобильном во весь экран)
|
||||
**Шапка:** заголовок карточки (редактируемый), меню (⋯): «**Открыть в боковом виде**» (Open in
|
||||
sidebar view) / «**Открыть в большом виде**» (Open in bigger view). Подпись «Изменено … ⸱ Создано …».
|
||||
**Табы (по порядку)** — `сверено:` на forbion таб Details назван «**Свойства**», далее
|
||||
«**Вложения**», «**Комментарии**», «**Активность**»:
|
||||
- **Свойства** (Details) — `CardSidebarTabDetails`, порядок секций (сверено на живой карточке:
|
||||
метка → назначение → срок → описание):
|
||||
1. «**Назначить метку карточке…**» (`TagSelector`) — комбобокс «Выбрать или создать метку…»,
|
||||
можно «Создать новую метку».
|
||||
2. «**Назначить пользователю/группе/команде**» (`AssignmentSelector`) — «Выбрать пользователя…».
|
||||
3. «**Назначить срок карточке…**» (`DueDateSelector`) — «Установить срок» / «Добавить срок»;
|
||||
быстрые опции: «Позже сегодня», «Завтра», «В эти выходные», «На след. неделе»; «**Выбрать
|
||||
дату**» (Choose a date) → пикер; «**Убрать срок**» (Remove due date); «**Отметить
|
||||
выполненной**» (Mark as done); при наличии — «Срок: …»/«Не выполнено»; «Архивировать
|
||||
карточку».
|
||||
4. (если включены проекты) блок связанных проектов (`NcCollectionList`).
|
||||
5. «**Описание**» (`Description`) — «Редактировать описание» / «Просмотр описания», «Справка по
|
||||
форматированию», «Добавить вложение», плейсхолдер «Напишите описание …»; статусы «(Не
|
||||
сохранено)»/«(Сохранение…)».
|
||||
- **Вложения** (Attachments) — `CardSidebarTabAttachments` (+ `AttachmentList`): «**Загрузить новые
|
||||
файлы**» (Upload new files), «**Поделиться из Файлов**» (Share from Files); по элементу:
|
||||
«Показать в Файлах», «Скачать» (Download), «Удалить вложение», «Восстановить вложение»; drop-зона
|
||||
«Перетащите файлы для загрузки»; «Эта доска только для чтения».
|
||||
- **Комментарии** (Comments) — `CardSidebarTabComments` (+ `CommentForm`): поле ввода + «Сохранить»;
|
||||
пусто «Пока нет комментариев. Начните обсуждение!».
|
||||
- **Активность** (Activity) — `CardSidebarTabActivity` (если включён Activity-апп).
|
||||
*(все действия табов — write, на forbion не выполняю.)*
|
||||
|
||||
## Сайдбар доски (`BoardSidebar` — во весь экран на мобильном)
|
||||
Табы: «**Общий доступ**» (Sharing) — `SharingTabSidebar`: «Поделиться доской с пользователем,
|
||||
группой или командой …», права по ACL (Can edit / Can share / Can manage / Owner), «Передать
|
||||
доску» (Transfer) с подтверждением, «Удалить» доступ; «**Метки**» (Tags) — `TagsTabSidebar`:
|
||||
список меток, «Добавить новую метку», по метке — «Редактировать»/«Удалить»; «**Удалённые
|
||||
элементы**» (Deleted items) — `DeletedTabSidebar`: «Удалённые списки» / «Удалённые карточки»
|
||||
(восстановление); «**Активность**» (Activity). *(все write — не выполняю.)*
|
||||
|
||||
## Диалог: Переместить/копировать карточку (`CardMoveDialog`)
|
||||
- «**Выбрать доску**» (Select a board) — комбобокс.
|
||||
- «**Выбрать список**» (Select a list; «Нет доступных списков», если пусто).
|
||||
- Кнопки «**Переместить карточку**» (Move card) / «**Копировать карточку**» (Copy card).
|
||||
|
||||
## Модалка: Настройки Deck (`DeckAppSettings` — во весь экран на мобильном)
|
||||
- **Общее** (General): «**Использовать увеличенный вид карточки**» (Use bigger card view).
|
||||
- **Внешний вид** (Appearance): «**Показывать бейдж ID карточки**» (Show card ID badge),
|
||||
«**Показывать доски в календаре/задачах**» (Show boards in calendar/tasks).
|
||||
- **Настройки администратора** (Admin settings, если админ): «Ограничить создание досок группами».
|
||||
- **Комбинации клавиш** (`KeyboardShortcuts`): действия доски (Scroll sideways, навигация
|
||||
стрелками, Escape закрыть карточку, Ctrl+F поиск, F фильтры, X сброс фильтров, ? справка) и
|
||||
карточки (Enter/Space открыть, E правка заголовка, S назначить себя). В теме forbion секция
|
||||
клавиш может быть скрыта на мобильном — `визуал(?)`.
|
||||
|
||||
## Расхождения с нативным клиентом (`feature/deck`) — бэклог
|
||||
Нативный модуль (`DeckRepository`/`DeckScreen`/`DeckCardSheet`/`DeckViewModel`) сейчас покрывает:
|
||||
список неархивных досок, доску колонками, карточки (метки/срок/исполнители-**чтение**/done/
|
||||
счётчик), деталь карточки (правка названия/описания, срок через пикер, done, toggle меток,
|
||||
перенос между колонками, архив, удаление), создание карточки и колонки, учёт PERMISSION_EDIT.
|
||||
**Отсутствует против веба (приоритетный бэклог):**
|
||||
- **Обзор/Предстоящие карточки** — дашборда по срокам (Просрочено/Сегодня/…/Без срока) нет.
|
||||
- **Архивные доски** и **Доски, доступные вам** — отдельных видов нет (архивные доски просто скрыты).
|
||||
- **Фильтры доски** — по метке / назначенному / статусу; поиска по доске нет.
|
||||
- **Исполнители — только чтение**: нет «Назначить мне/пользователю», снятия (совпадает с долгом №1
|
||||
плана mail/071, п.2).
|
||||
- **Комментарии** и **Вложения** карточки — нет совсем (вложения явно в зоне, mail/065).
|
||||
- **Меню/CRUD колонки**: правка заголовка списка, «Архивировать/Разархивировать все карточки»,
|
||||
«Удалить список» — в приложении нет (только создание).
|
||||
- **CRUD досок**: создать/править/клонировать/архивировать/экспортировать/импортировать доску —
|
||||
нет; переименования колонок нет.
|
||||
- **Move/copy на другую доску** — нативно только перенос между колонками одной доски.
|
||||
- **Метки (Tags) доски** — управления (создать/править/удалить) нет; toggle существующих есть.
|
||||
- **Sharing / Deleted items / Activity** доски — нет.
|
||||
- **Быстрые опции срока** (Позже сегодня/Завтра/Выходные/След. неделя) — в пикере нет.
|
||||
- **Режимы просмотра доски** (`сверено:` шапка forbion): показ архивных карточек, компактный/
|
||||
обычный режим, обложки карточек — в нативном клиенте нет.
|
||||
- **Напоминания о сроках** (notify-due) и **бейдж ID / увеличенный вид** из настроек — нет.
|
||||
- **Undo** при удалении карточки — в вебе есть тост-отмена; в нативном удаление сразу.
|
||||
- Мелочь UI: счётчики чек-пунктов и комментариев на карточке доски — не показываются.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Расхождения: Файлы (web forbion ↔ нативный feature/files)
|
||||
|
||||
Список несоответствий по методу mail/081 (итерация сверки). Вход — карта
|
||||
[`files.md`](files.md). Сверка **по коду** `feature/files` (состав/функции); попиксельная
|
||||
вёрстка по CSS-теме и визуал — отдельным проходом после сборки на устройстве.
|
||||
|
||||
Обозначения: **[Ф]** функционал · **[Д]** дизайн/вёрстка · приоритет P1(высокий)…P3(низкий).
|
||||
Статус: `open` — не начато, `wip` — в работе, `done` — закрыто и сверено повторно.
|
||||
|
||||
## Итерация 1 — сверка по коду (2026-07-12)
|
||||
|
||||
| # | Элемент/функция | В вебе | В нативе | Тип | Приор. | Статус |
|
||||
|---|---|---|---|---|---|---|
|
||||
| D1 | Панель «Подробно» — вкладки | 4: Общий доступ / **Версии** / **Чат** / События | 2: Sharing, Events (`FileDetailsTab`, FilesModels.kt:67) — нет «Версии», «Чат/комментарии» | Ф | P2 | open |
|
||||
| D2 | Фильтры-чипы | «Тип файла» (мультивыбор 8 типов), «Изменён» (5 пресетов дат), «Участники» (по людям) | Чипы есть, но обработчики — заглушки: Тип/Изменён → сортировка, Участники → пустой `{}` (FilesScreen.kt:224-226). Фильтрации нет | Ф | P1 | open |
|
||||
| D3 | Групповые действия (bulk) | 6: избранное, метки, переместить/копировать, скачать, архивировать, удалить | 3: Favorite, Download, Delete (`FilesBulkAction`, FilesComponents.kt:1064); Download → заглушка `showWebOnlyMessage` | Ф | P1 | open |
|
||||
| D4 | Шэры — создание | Внутренние ссылки: пользователь (type 0) / группа (type 1) + публичная (type 3) | Создаётся только `shareType=3` публичная ссылка (`createPublicShareLink`, FilesApiRepository.kt:174); чтение чужих шэров есть (`fetchShares` :199) | Ф | P1 | open |
|
||||
|
||||
Снято (не расхождение): меню «Новый» — нативный `FilesCreateMenu.kt` содержит весь веб-список
|
||||
(загрузка + 10 «Создать новое») и даже пункт сверх («Добавить описание папки»).
|
||||
|
||||
## Инвентарь открывашек файлов (для медиа-воркстрима, mail/081 §3)
|
||||
|
||||
Текущее состояние нативных просмотрщиков в `feature/files`:
|
||||
|
||||
| Тип | Форматы | Как открывается сейчас | Пробел |
|
||||
|---|---|---|---|
|
||||
| Изображения | jpg/jpeg/png/gif/webp/bmp/heic/heif (`OpenableFiles.kt:10`) | Нативно `ImageViewerActivity` (Coil AsyncImage) | — (svg нет в списке — добавить?) |
|
||||
| Офис | doc/docx/xls/xlsx/xlsm/… (`OfficeFiles.kt`) | Collabora/Richdocuments в WebView | — (есть) |
|
||||
| Текст/Markdown | md/markdown/txt/text/org/log (`WebOpenableFiles.kt:17`) | Нативный `MarkdownEditorScreen` | — (есть) |
|
||||
| Доска | .whiteboard (`WebOpenableFiles.kt:9`) | WebView (Excalidraw, realtime — нативно не делаем) | — (осознанно web) |
|
||||
| **PDF** | pdf | Нет нативного вьюера → системный интент/`LocalFileOpener` или «тип не поддерживается» (FilesViewModel.kt:495) | **[Ф] P1 — нужен нативный PDF-вьюер** |
|
||||
| **Видео** | mp4/webm/mkv/mov | Нет нативного плеера → системный интент | **[Ф] P2 — нативный видеоплеер** |
|
||||
| **Аудио** | mp3/aac/ogg/wav/flac | Нет нативного плеера → системный интент | **[Ф] P2 — нативный аудиоплеер** |
|
||||
|
||||
Медиа-архитектуру (нативные плееры vs webview/интенты) определяет лид — жду письмо-раскладку
|
||||
(mail/081 §3). До неё PDF/видео/аудио уходят в системный `ACTION_VIEW` (chooser «Открыть с помощью»).
|
||||
|
||||
## Осталось для итерации 2 (нужна сборка на устройстве)
|
||||
- [Д] попиксельная вёрстка всех экранов по CSS-теме `_mobile-files.css`.
|
||||
- [Ф] дерево «Поиска», переходы после открытия PDF/аудио/видео (визуальная сверка).
|
||||
@@ -0,0 +1,132 @@
|
||||
# Карта: Файлы
|
||||
|
||||
Источники (метод mail/078): **живой мобильный web** forbion (сессия Browser 1, аккаунт
|
||||
`cloude`, iframe ~390px, read-only — mail/073/075/079) для внешнего вида/порядка +
|
||||
**апстрим `nextcloud/server apps/files/src`** для состава кнопок/меню (сверено: `filters/`,
|
||||
`actions/`) + тема `forbion-theme/.../_mobile-files.css` (селекторы элементов окна).
|
||||
Приватность: реальные ФИО/почты сотрудников, права доступа и инвентарь бизнес-папок не
|
||||
фиксируются — только структура UI и дерево переходов.
|
||||
|
||||
## Вход
|
||||
Приложение «Файлы» — стартовое (нижняя панель / шторка приложений «Файлы»). URL `/apps/files/files`.
|
||||
Оболочку (нижняя панель, шторка, бургер) описывает `shell.md` (зона лида) — здесь только
|
||||
специфика приложения.
|
||||
|
||||
## Экран: Все файлы (главный / список папки)
|
||||
**Шапка (сверху вниз):**
|
||||
- Пилюля «Поиск» (Объединённый поиск) → экран поиска (см. ниже).
|
||||
- Иконка-сетка справа → «Переключиться на режим просмотра сеткой» (тумблер список⇄сетка).
|
||||
- Кнопка «Открыть навигацию [n]» → шторка навигации файлов (см. «Навигация файлов»).
|
||||
- Кнопка «Новый» → меню создания/загрузки (см. «Меню Новый»).
|
||||
- Хлебные крошки: 🏠 (корень) → сегменты пути; каждый сегмент кликабелен → переход к папке.
|
||||
- Чипы-фильтры: «Тип файла», «Изменён», «Участники» (в корне — три; внутри папки «Участники»
|
||||
пропадает, остаются «Тип файла»/«Изменён»).
|
||||
|
||||
**Тело:**
|
||||
- Блок недавних/предложенных файлов (горизонтальная лента карточек: иконка типа + имя +
|
||||
«Последнее обновление: …»); тап по карточке → открытие файла.
|
||||
- Список папок/файлов (строки): чекбокс выделения · иконка типа (звёздочка = избранное) ·
|
||||
имя · инлайн-кнопка «Скачать» (для файлов) · кнопка «Действия» (⋮).
|
||||
- Тап по строке-папке → открыть папку (крошки наращиваются, URL `?dir=/Путь`).
|
||||
- Тап по строке-файлу → открыть файл (по типу, см. «Открытие файла»).
|
||||
- Чекбокс → режим выделения (см. «Режим выделения»).
|
||||
- «Действия» (⋮) → контекстное меню файла/папки (см. ниже).
|
||||
|
||||
**Пустое состояние (папка без файлов):** иконка + «Здесь нет файлов» + подзаголовок
|
||||
«Загрузите что-нибудь или синхронизируйте со своими устройствами!».
|
||||
|
||||
## Меню «Новый» (создание/загрузка)
|
||||
Открывается кнопкой «Новый». Два раздела:
|
||||
- **Загрузить с устройства:** «Загрузить файлы», «Загрузить папки».
|
||||
- **Создать новое:** «Новая папка», «Запрос на создание файла», «Новая диаграмма»,
|
||||
«Новая доска», «Новая презентация», «Новая таблица», «Новый документ»,
|
||||
«Новый текстовый файл», «Создать папку шаблонов».
|
||||
- (read-only: пункты только просмотрены, ничего не создавалось.)
|
||||
|
||||
## Навигация файлов (шторка «Открыть навигацию»)
|
||||
Список представлений + дерево:
|
||||
- «Все файлы», «Личные файлы», «Недавно изменённые», «Избранные»,
|
||||
«Опубликованные ресурсы», «Командные папки», «Метки», «Внешнее хранилище»,
|
||||
«Дерево папок», «Корзина».
|
||||
- Внизу — индикатор квоты («Использовано N GB»).
|
||||
- Каждый пункт → соответствующее представление списка файлов.
|
||||
|
||||
## Фильтры
|
||||
- **«Тип файла»** → мультивыбор типов: Документы, Таблицы, Презентации, PDFs, Папки, Звук,
|
||||
Изображения, Видео (`TypeFilter.ts`).
|
||||
- **«Изменён»** → пресеты по дате (`ModifiedFilter.ts`): Сегодня, Последние 7 дней,
|
||||
Последние 30 дней, В этом году, В прошлом году.
|
||||
- **«Участники»** → фильтр по людям с доступом (даёт `files_sharing`, не ядро files) — чип есть
|
||||
в корне «Все файлы», внутри папки пропадает.
|
||||
|
||||
## Контекстное меню (⋮) — Папка
|
||||
«Добавить в избранное», «Подробно», «Варианты обмена», «Управление метками»,
|
||||
«Переименовать», «Переместить или копировать», «Установить напоминание» ›,
|
||||
«Открыть локально», «Скачать», «Архивировать в...» ›, «Удалить папку».
|
||||
(Сверено с `apps/files/src/actions/`: favorite, sidebar=Подробно, moveOrCopy, rename,
|
||||
editLocally=«Открыть локально», download, delete, openFolder; метки/обмен/напоминание/архив —
|
||||
из соответствующих приложений тенанта.)
|
||||
|
||||
## Контекстное меню (⋮) — Файл
|
||||
«Добавить/Удалить из избранных», «Подробно», строка-статус общего доступа (если расшарен),
|
||||
«Управление метками», «Переименовать», «Переместить или копировать»,
|
||||
«Установить напоминание» ›, «Сохранить как…» › (для офисных — форматы),
|
||||
«Открыть локально», «Скачать», «Архивировать в...» ›, «Удалить файл».
|
||||
|
||||
## Режим выделения (bulk)
|
||||
Отметка чекбокса → шапка списка меняется на: «Выбран N» (слева) + кнопка «Действия» (справа).
|
||||
Меню «Действия» (групповое): «Добавить в избранное», «Управление метками»,
|
||||
«Переместить или копировать», «Скачать», «Архивировать в...» ›, «Удалить папку/файлы».
|
||||
|
||||
## Экран: Подробно (панель сведений о файле)
|
||||
Открывается пунктом «Подробно» в ⋮. Шапка: имя файла · × закрыть · владелец/аватар ·
|
||||
«<дата изменения> / <размер>». Четыре вкладки-иконки:
|
||||
- **Общий доступ** (людской значок): подсекции «Внутренние ссылки» (поле «Введите имена или
|
||||
команды», список получателей с выпадающим правом «Можно редактировать/…», «Другие
|
||||
пользователи, имеющие доступ», «Внутренняя ссылка / Для тех, у кого уже есть доступ» +
|
||||
копировать), «Внешние ссылки» (поле e-mail, «Создать общедоступную ссылку» +),
|
||||
«Дополнительные ссылки». Внизу — ряд быстрых иконок связанных приложений.
|
||||
- **Версии** — история версий файла.
|
||||
- **Чат** — комментарии к файлу.
|
||||
- **События** — активность/лента событий по файлу.
|
||||
- (read-only: панель только просмотрена; персональные данные получателей не фиксируются.)
|
||||
|
||||
## Открытие файла (по типу)
|
||||
Тап по файлу открывает вьюер/редактор по типу (наблюдалось частично; часть — (?) по иконкам/
|
||||
категориям типов):
|
||||
- Офисные (Таблицы/Документы/Презентации) → редактор Collabora/Richdocuments в WebView.
|
||||
- `.md` / текст → редактор текста/markdown.
|
||||
- Изображения → просмотрщик изображений.
|
||||
- `.whiteboard` / «доска» → редактор доски (WebView).
|
||||
- PDF → просмотрщик PDF. *(точный переход для PDF/аудио/видео — (?), не открывал.)*
|
||||
|
||||
## Экран: Поиск
|
||||
Пилюля «Поиск» → объединённый поиск (Nextcloud unified search) по файлам и приложениям.
|
||||
*(детальное дерево поиска не раскрывал — (?).)*
|
||||
|
||||
## Расхождения с нативным клиентом (сверено по коду `feature/files`, 2026-07-12)
|
||||
Сверка **состава/функций** по коду (не попиксельная — та ждёт сборки на устройстве, задача №1
|
||||
mail/068). Итог: **4 подтверждённых расхождения, 1 снято.**
|
||||
|
||||
- ✅ снято — меню «Новый»: нативный `FilesCreateMenu.kt` содержит весь веб-список (загрузка +
|
||||
10 пунктов «Создать новое») и даже больше («Добавить описание папки»). Соответствует.
|
||||
- `расхождение:` **панель «Подробно» — 2 вкладки вместо 4.** `FileDetailsTab` (FilesModels.kt:67)
|
||||
= {Sharing, Events}; в вебе 4 (Общий доступ / **Версии** / **Чат** / События). Нет вкладок
|
||||
«Версии» и «Чат/комментарии».
|
||||
- `расхождение:` **фильтры-чипы — заглушки.** `FilesFilterChips` рисует «Тип файла/Участники/
|
||||
Изменен», но обработчики (FilesScreen.kt:224-226): «Тип файла»→сортировка по типу,
|
||||
«Изменен»→сортировка по дате, «Участники»→пустой `{}`. В вебе это фильтры: Тип файла
|
||||
(мультивыбор 8 типов), Изменён (5 пресетов дат), Участники (по людям). Функционал фильтрации
|
||||
отсутствует.
|
||||
- `расхождение:` **групповые действия — 3 из 6.** `FilesBulkAction` (FilesComponents.kt:1064)
|
||||
= {Favorite, Download, Delete}, причём Download → `showWebOnlyMessage("Массовое скачивание")`
|
||||
(заглушка). В вебе 6: Добавить в избранное, Управление метками, Переместить или копировать,
|
||||
Скачать, Архивировать в…, Удалить. Нет меток/перемещения-копирования/архива; массовое
|
||||
скачивание не реализовано.
|
||||
- `расхождение:` **шэры — только публичная ссылка.** `createPublicShareLink` (FilesApiRepository.kt:174)
|
||||
создаёт лишь `shareType=3`. `fetchShares` (:199) читает и отображает существующие шэры любого
|
||||
типа, но СОЗДАНИЕ внутренних шэров (пользователь=0/группа=1) отсутствует. В вебе панель
|
||||
«Внутренние ссылки» это умеет. = план п.3 mail/068.
|
||||
|
||||
Осталось (нужна сборка на устройстве, попиксельно): вёрстка по CSS-теме, переходы PDF/аудио/видео,
|
||||
дерево поиска.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Diff: Почта (веб forbion ↔ нативный `feature/mail`)
|
||||
|
||||
Итерация 1 (по mail/081). Вход — карта `mail.md` (живой forbion, аккаунт `cloude`, read-only) +
|
||||
код модуля `feature/mail` на 91aaaba. Тип: **Ф** — функционал, **Д** — дизайн/вёрстка.
|
||||
Приоритет: P1 (болит/ядро), P2 (заметно), P3 (мелочь/косметика).
|
||||
Статус: `open` — есть в вебе, нет/иначе в нативе; `device?` — требует проверки на устройстве.
|
||||
|
||||
> Общий вывод: нативный клиент функционально зрелый — расширенный поиск (все поля),
|
||||
> подтверждение прочтения, важность/спам/метки/переместить/создать задачу/создать событие/
|
||||
> запланированная отправка **уже есть и совпадают**. Ниже — только реальные пробелы.
|
||||
|
||||
## Функциональные
|
||||
|
||||
| # | Элемент / функция | В вебе (forbion) | В нативе (`feature/mail`) | Тип | Приор. | Статус |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Черновики | Папка «Черновики» + композер сохраняет и восстанавливает черновик | Композер **не сохраняет** черновик — набранное теряется (`MailComposeViewModel`) | Ф | **P1** | open |
|
||||
| 2 | Композер «⋯» → «Текстовые шаблоны» | Вставка шаблона/подписи в тело письма | Текстовые блоки только в настройках (`MailRepository`), в композер не вставляются | Ф | P2 | open |
|
||||
| 3 | Композер «⋯» → «Умный подборщик» (smart picker) | Есть (вставка ссылок/упоминаний/виджетов) | Нет | Ф | P3 | open |
|
||||
| 4 | Меню письма → «Ответить встречей» | Создание ответа-приглашения (iMIP) | Нет (есть смежное «Создать событие» `MailComponents.kt:1254`) | Ф | P3 | open |
|
||||
| 5 | Меню письма → «Скачать письмо» (.eml) | Выгрузка исходника письма | Нет | Ф | P3 | open |
|
||||
| 6 | Меню письма → «Редактировать как новое сообщение» | Открывает композер с телом письма | Нет | Ф | P3 | open |
|
||||
| 7 | Действие «Архивировать» над письмом | Отдельная кнопка «в архив» | Нет отдельного пункта; архив только через «Переместить» (`MailComponents.kt:1259`) | Ф | P3 | open |
|
||||
|
||||
## Дизайн / вёрстка
|
||||
|
||||
| # | Элемент | В вебе (forbion) | В нативе | Тип | Приор. | Статус |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 8 | Подписи виртуальных папок | «Непрочитанное», «Избранные» | «Непрочитанные», «Избранное» (`MailRepository.kt:672-673`) | Д | P2 | open |
|
||||
| 9 | Порядок папок | Приоритетные→Входящие→Непрочитанное→Избранные→Черновики→Отправленные→Спам→Корзина | Входящие→Непрочитанные→Избранное→Отправленные→Черновики→Архив→Корзина→Спам (`MailRepository.kt:671-680`) | Д | P2 | open |
|
||||
| 10 | Быстрые действия строки списка | Inline-иконки при наведении (✉/🌿/🗑/⋯) | Свайп справа-налево (`f7SwipeFromRightToDismiss`) | Д | P3 | **device?** |
|
||||
|
||||
## Требуют device-сверки (карта live не покрыла)
|
||||
|
||||
| # | Что | Причина | Статус |
|
||||
|---|---|---|---|
|
||||
| A | Экран открытого письма/треда: панель действий (Ответить/Переслать/Архив/Спам/Корзина/Удалить), меты, вложения, HTML-тело | live не проходился — открытие непрочитанного = запись `\Seen`; структура достроена по `MessageView.vue`+макетам | device? |
|
||||
| B | Модалка «Параметры эл. почты» по подразделам (native — 6 разделов) | JS-модалка, live не раскрыта по пунктам | device? |
|
||||
| C | Диалоги «Изменить метки» / «Переместить» / «Запланированная отправка» — состав форм | live не раскрывались (пишущие) | device? |
|
||||
| D | Действия «⋯» по папке и по аккаунту в шторке навигации | live не раскрывались | device? |
|
||||
| E | Проверить #10: hover-иконки vs свайп — не артефакт ли desktop-web на узком экране | нужен реальный мобильный web/устройство | device? |
|
||||
|
||||
## Примечание по методу
|
||||
- Пункт #10 помечен `device?`: наблюдённые inline-hover-действия могут быть поведением
|
||||
desktop-web в узком вьюпорте, а не мобильным паттерном — до проверки на реальном мобильном
|
||||
не тащить в реализацию.
|
||||
- Метрику ≤2% (mail/081 п.2) считаю по чек-листу «элементы+функции на экран» — жду формулу лида.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Карта: Почта
|
||||
|
||||
Снята по **мобильной веб-версии** forbion.f7cloud.ru (вьюпорт ~400px, плавающая нижняя панель —
|
||||
мобильная тема), аккаунт `cloude` / почтовый ящик `cloude@forbion.ru`, **строго read-only**
|
||||
(mail/073). Живой UI — полный **Nextcloud Mail 5.7.15**.
|
||||
|
||||
**Источники узлов:** `[live]` — пройдено в браузере; `[src]` — достроено по читаемому исходнику
|
||||
`design-reference/forbion-apps/f7mail/src` (форк f7mail) + nextcloud/mail 5.7.15, т.к. узел
|
||||
требует записи и live не проходился; `[mockup]` — сверено с `design/screens/mail/*.png`.
|
||||
|
||||
> ⚠️ **Ограничение read-only для Почты.** В NC Mail пометкой `\Seen` является само **открытие**
|
||||
> непрочитанного письма — это запись на сервер. У `cloude` все 3 письма во «Входящих» —
|
||||
> непрочитанные, «Отправленные»/«Черновики» пусты, прочитанных писем нет. Поэтому **экран
|
||||
> открытого письма live не проходился**; его дерево дано по `[src]`/`[mockup]` и помечено
|
||||
> `не-пройдено-live: \Seen`. Composer открывался пустым и закрывался без ввода (черновик не
|
||||
> создавался). Ни одно пишущее действие (отправка/удаление/спам/перемещение/пометки) не нажималось.
|
||||
|
||||
---
|
||||
|
||||
## Вход
|
||||
Нижняя плавающая панель оболочки → иконка «конверт» (раздел «Почта», активна) → открывается
|
||||
**Список писем** текущей папки (по умолчанию «Приоритетные входящие», `/apps/mail/box/priority`).
|
||||
Оболочку панели/шторки описывает `shell.md` (зона лида).
|
||||
|
||||
---
|
||||
|
||||
## Экран: Список писем (папка) `[live]`
|
||||
URL: `/apps/mail/box/<id>` (напр. priority, 517=Входящие).
|
||||
|
||||
**Шапка:**
|
||||
- Поле «Искать в папке» → фокус строки поиска в текущей папке.
|
||||
- Иконка-слайдеры (справа в поле) → открывает модалку **«Параметры поиска»** (расширенный поиск).
|
||||
- Круглая зелёная «+» → открывает **Композер** (сворачивается в плашку «Сообщение без названия»).
|
||||
|
||||
**Тело:** вертикальный список карточек-писем. Пустая папка → центр-заглушка «**В этой папке нет
|
||||
сообщений**» + логотип F7 `[live]` (наблюдалось в «Отправленных»).
|
||||
|
||||
**Карточка письма (строка):**
|
||||
- Аватар/инициалы отправителя (цветной кружок).
|
||||
- Имя отправителя (жирным) + строка-превью темы/начала письма.
|
||||
- Справа: относительное время («1:00», «вс») + **зелёная точка** = непрочитано.
|
||||
- Тап по карточке → **Экран открытого письма/треда** (`/box/<id>/thread/<tid>`).
|
||||
⚠️ помечает `\Seen` — live не нажималось.
|
||||
|
||||
**Быстрые действия строки (появляются при наведении/выборе)** `[live]`:
|
||||
- ✉ конверт → пометить прочитанным/непрочитанным *(запись)*.
|
||||
- 🌿 лист → важное/снять важность *(запись)*.
|
||||
- 🗑 корзина → удалить ветку *(запись)*.
|
||||
- ⋯ → контекстное меню (ниже).
|
||||
|
||||
### Контекстное меню письма (⋯ «Больше действий») `[live]`
|
||||
Верхний ряд иконок: **В избранное** (звезда) · **Прочитать** (конверт) · **Снять важность** (лист).
|
||||
Далее пункты:
|
||||
- 🕐 `<дата и время письма>` (инфо-строка, напр. «13 июля 2026 г., 1:00»).
|
||||
- ⓘ **Пометить как спам** *(запись)*.
|
||||
- 🏷 **Изменить метки** → диалог выбора/назначения меток *(запись при сохранении)*.
|
||||
- ⏰ **Запланированная отправка** *(в native — открывает composer-ответ с выбором времени)*.
|
||||
- **Переместить ветку** → выбор папки назначения *(запись)*.
|
||||
- 🗑 **Удалить ветку** *(запись)*.
|
||||
- ⋯ **Больше действий** → подменю:
|
||||
- **Редактировать как новое сообщение** → Композер с телом письма.
|
||||
- **Ответить встречей** → создание события-приглашения (CalDAV).
|
||||
- **Создать задачу** → создание задачи из письма (Tasks).
|
||||
- **Скачать письмо** → выгрузка .eml.
|
||||
|
||||
---
|
||||
|
||||
## Экран: Параметры поиска (расширенный поиск) `[live]`
|
||||
Открывается иконкой-слайдерами в поле поиска. Модалка «**Параметры поиска**», крестик ✕ (закрыть).
|
||||
**Поля:**
|
||||
- **Тема** (строка).
|
||||
- **Тело** — «Найти» (строка).
|
||||
- **Дата**: «Дата начала» и «Дата окончания» — два пикера даты (иконка календаря).
|
||||
- **От** — «Выберите отправителей» (мультиселект).
|
||||
- **Кому** — «Выберите получателей».
|
||||
- **Коп** — получатели CC.
|
||||
- **СкрКоп** — получатели BCC.
|
||||
- **Метки** — «Выбор меток».
|
||||
- **Отмечено как** (чекбоксы): Важное · В избранное · Непрочитанное · Имеет вложения ·
|
||||
Упоминания меня.
|
||||
|
||||
**Низ:** «Очистить» (сброс) · «**Поиск**» (зелёная).
|
||||
|
||||
---
|
||||
|
||||
## Экран: Композер (новое сообщение) `[live]`
|
||||
Открытие: «+» в шапке списка. Сначала **свёрнутая плашка** снизу «Сообщение без названия»
|
||||
с иконками ⤢ (развернуть) и ✕ (закрыть/отменить). Развёрнутый вид — полноэкранная форма:
|
||||
|
||||
**Шапка:** заголовок «Новое сообщение», под ним отправитель «cloude <cloude@forbion.ru>»;
|
||||
справа — («свернуть») и ✕ («закрыть»).
|
||||
**Поля:**
|
||||
- **Кому:** с шевроном-раскрытием → раскрывает **Копия:** (Cc) и **Ск:** (Bcc).
|
||||
- **Тема сообщения**.
|
||||
- **Текст** (тело, шеврон сворачивания); в правом нижнем углу тела — 📎 **скрепка** (вложения).
|
||||
**Низ-панель:**
|
||||
- **≡A** → панель форматирования текста (rich-text: жирный/курсив/списки и т.п.) `[src]`.
|
||||
- **⋯** → меню: **Умный подборщик** · **Текстовые шаблоны** · **Отправить позже** ·
|
||||
☐ **Запросить подтверждение прочтения**.
|
||||
- **▷ Отправить** (зелёная, неактивна при пустых получателях) *(запись — не нажималось)*.
|
||||
|
||||
расхождение: native-композер «⋯» содержит только «Отправить позже» + «Запросить подтверждение
|
||||
прочтения» (`MailComposeScreen.kt:758,767`) — **нет «Умного подборщика» и «Текстовых шаблонов»**
|
||||
(см. Расхождения п.2–3). Запланированная отправка, подтверждение прочтения, вложения, rich-text
|
||||
(≡A) — совпадают.
|
||||
|
||||
---
|
||||
|
||||
## Экран: Открытое письмо / тред `[src] [mockup]` — не-пройдено-live: \Seen
|
||||
URL: `/apps/mail/box/<id>/thread/<tid>`. Структура по `MessageView.vue` + макетам
|
||||
`Почта _ Окно сообщения*.png`, `Сообщение с перепиской *.png`:
|
||||
|
||||
**Шапка:** тема письма (заголовок h2). Для локального (черновик/исходящее) — метка статуса.
|
||||
**Панель действий над письмом:**
|
||||
- Обычное письмо: **Ответить** · **Переслать** · **В избранное** (toggle) · **Отметить
|
||||
непрочитанным** (если `\Seen`) · **Архивировать** · **В спам** · **В корзину** · **Удалить**.
|
||||
- Черновик/исходящее: **Отправить** (если можно) · **Изменить черновик** · **Удалить**.
|
||||
**Мета-строки:** Кому (to), Копия (cc), «Отправить в» (sendAt — для запланированных).
|
||||
**Перемещение:** выпадающий выбор папки + кнопка «Переместить».
|
||||
**Вложения:** список карточек вложений (если есть).
|
||||
**Тело:** HTML-письмо в WebView-контейнере, либо текстовое в Compose (в native).
|
||||
**Тред:** несколько писем переписки — сворачиваемые карточки (закрытые/открытое), см.
|
||||
`Сообщение с перепиской закрытые.png` / `... открытое.png`.
|
||||
**Плавающая кнопка «↩ Ответить»** (native `MailReplyFab`) — сверено ранее, `[mockup]`.
|
||||
|
||||
---
|
||||
|
||||
## Навигация папок (шторка «Открыть навигацию») `[live]`
|
||||
Кнопка-гамбургер почты → выезжает список папок ящика. Сверху: «**Создать сообщение**» и
|
||||
«**Обновить**». Далее:
|
||||
- **Приоритетные входящие** (`/box/priority`).
|
||||
- Аккаунт **cloude@forbion.ru** + кнопка «**Действия**» (⋯ по аккаунту).
|
||||
- **Входящие** (`/box/517`) · **Непрочитанное** (`/box/unread/517`) · **Избранные**
|
||||
(`/box/starred/517`) · **Черновики** (`/box/516`) · **Отправленные** (`/box/515`) ·
|
||||
**Спам** (`/box/513`) · **Корзина** (`/box/514`).
|
||||
- У каждой папки — кнопка «**Действия**» (⋯: переименование/очистка/подписка и т.п. `[src]`).
|
||||
|
||||
---
|
||||
|
||||
## Экран: Параметры эл. почты (настройки) `[live] вход` / `[src] глубина`
|
||||
Точка входа: пункт «**Параметры эл. почты**» (в меню настроек шапки; JS-модалка, `href="#"`).
|
||||
Глубину подтверждает native `MailSettingsScreens.kt` + макеты `Параметры электронной почты.png`,
|
||||
`Параметры учётной записи.png`:
|
||||
- **Основные** (общие параметры почты).
|
||||
- **Учётные записи** (аккаунты; форма `AccountForm.vue` `[src]`).
|
||||
- **Внешний вид**.
|
||||
- **Текстовые шаблоны / подписи**.
|
||||
- **Конфиденциальность**.
|
||||
- **Безопасность**.
|
||||
- **Фильтры** (sieve) — `FiltersSettingsView.vue` `[src]`; в native — общий официальный API
|
||||
`/apps/mail/api/filter` (список/вкл/выкл/удаление/правка простых; сложные — долг v1).
|
||||
|
||||
расхождение(?): live-модалку «Параметры эл. почты» не раскрывал по пунктам (JS-диалог) — при
|
||||
сверке на устройстве пройти каждый подраздел и сверить состав с native.
|
||||
|
||||
---
|
||||
|
||||
## Пустые/особые состояния `[live]`
|
||||
- Пустая папка: «В этой папке нет сообщений» + логотип F7 по центру.
|
||||
- Свёрнутый композер: плашка «Сообщение без названия» (⤢ / ✕) поверх списка.
|
||||
|
||||
---
|
||||
|
||||
## Расхождения с нативным `feature/mail`
|
||||
Полный список расхождений (веб ↔ натив) вынесен в **[`mail.diff.md`](mail.diff.md)** — формат
|
||||
итерации сверки по mail/081 (функция/элемент, тип Ф/Д, приоритет, статус). Кратко:
|
||||
нативный клиент функционально зрелый (расширенный поиск, подтверждение прочтения,
|
||||
важность/спам/метки/задача/событие/запланированная отправка — совпадают). Пробелы:
|
||||
- **P1 функц.:** черновики не сохраняются из композера.
|
||||
- **P2:** «Текстовые шаблоны» в композере; подписи и порядок виртуальных папок.
|
||||
- **P3:** «Умный подборщик», «Ответить встречей», «Скачать письмо», «Редактировать как новое»,
|
||||
отдельное «Архивировать»; паттерн быстрых действий строки (hover vs свайп — device?).
|
||||
- **device?:** экран открытого письма, модалка настроек, диалоги меток/перемещения — live не
|
||||
проходились (read-only / `\Seen`), см. `mail.diff.md` раздел «Требуют device-сверки».
|
||||
@@ -0,0 +1,152 @@
|
||||
# Diff: Заметки (Notes) — итерация 1 (2026-07-13)
|
||||
|
||||
Ff_app: **42.8%** · Fd_app: **0.0%** (среднее по 6 экранам; Fd ждёт девайс-сверки) · n-a: 0
|
||||
|
||||
> ✅ **Скелет заведён лидом** (`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).
|
||||
Метки: `[Ф|Д]` (функционал/дизайн) · `[open|done|n-a]`. Подход к реализации согласован —
|
||||
Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
|
||||
|
||||
Приоритеты предлагаю в конце (P1→P3); финальную приоритизацию — за лидом.
|
||||
|
||||
---
|
||||
|
||||
## Экран 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] меню строки «⋯» полное (сейчас на строке только избранное+тап; ⋯-поповер) — P2
|
||||
- [Ф][open] группировка по таймслотам (сегодня/недавние/…) — P2
|
||||
- [Ф][open] группировка по категориям (капшены) — P2
|
||||
- [Ф][done] сортировка «избранное вверх» + по дате изменения (`visibleNotes`)
|
||||
- [Ф][done] пустое состояние Welcome (`NotesEmptyState`: текст + «Новая заметка»)
|
||||
- [Ф][open] «Найти во всех категориях» (сброс фильтра при пустом поиске в категории) — P3
|
||||
- (состояние загрузки «Загрузка …» — Д)
|
||||
|
||||
**Д (6, open 6):** [Д][open] композиция экрана-списка · [Д][open] мобильная вёрстка по
|
||||
`_mobile-notes.css` (`content-list__search`, перенос) · [Д][open] порядок элементов шапки ·
|
||||
[Д][open] типографика (Raleway, тема) · [Д][open] иконки (звезда избранного, share-индикатор) ·
|
||||
[Д][open] состояние загрузки/пустое
|
||||
|
||||
## Экран 2. Меню заметки «⋯» (всплывающее, mail/087) — Ff 66.7% · Fd 0%
|
||||
Полный состав из кода (`NoteItem` #actions), порядок сверен визуально:
|
||||
**Ф (6, open 2):**
|
||||
- [Ф][done] «Добавить/Удалить из избранного» (`setFavorite`, юнит-тест) — данные готовы; UI P1-UI
|
||||
- [Ф][open] «Поделиться» → сайдбар шаринга (NC sharing) — P3
|
||||
- [Ф][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 40% · Fd 0%
|
||||
**Ф (10, open 6):**
|
||||
- [Ф][open] rich-редактор (@nextcloud/text) — P2
|
||||
- [Ф][open] панель форматирования: жирный/курсив/цитата/моно/очистить/список/нумерованный/
|
||||
заголовок/крупный заголовок/ссылка (всплывающая тулбар-панель, mail/087) — P2
|
||||
- [Ф][done] plain-режим (`NoteEditorScreen`, `F7OutlinedField` markdown) — UI собран
|
||||
- [Ф][open] тумблер «Просмотр»/«Правка» (preview⇄edit) — P2
|
||||
- [Ф][open] тумблер «Полный экран» — P3
|
||||
- [Ф][done] автосохранение с `If-Match: "<etag>"` (debounce → `saveContent`, юнит-тест 412)
|
||||
- [Ф][open] автозаголовок из первой строки (`PUT /autotitle`) — P2
|
||||
- [Ф][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 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] вёрстка диалога конфликта
|
||||
|
||||
## Экран 5. Навигация категорий (шторка) — Ff 0% · Fd 0%
|
||||
Всплывающие/контекстные меню категории (mail/087): «Новая категория», «⋯»-действия категории.
|
||||
**Ф (8, open 8):**
|
||||
- [Ф][open] «Новая категория» (создать; drag заметки → назначить) — P2
|
||||
- [Ф][open] «Все заметки» + счётчик (сброс фильтра) — P1
|
||||
- [Ф][open] «Без категории» + счётчик — P2
|
||||
- [Ф][open] список категорий + счётчики, фильтрация по тапу — P2
|
||||
- [Ф][open] «Переименовать категорию» (инлайн) — P3
|
||||
- [Ф][open] «Удалить категорию» + диалог подтверждения — P3
|
||||
- [Ф][open] вложенные подкатегории (`parent/child`) — P3
|
||||
- [Ф][open] вход «Настройки заметок» (шестерёнка) — P2
|
||||
|
||||
**Д (4, open 4):** [Д][open] композиция шторки навигации · [Д][open] иконки папок (тема — серая
|
||||
`folder-gray.svg`) · [Д][open] счётчики-бейджи · [Д][open] порядок (Все/Категории)
|
||||
|
||||
## Экран 6. Модалка «Настройки заметок» — Ff 0% · Fd 0%
|
||||
**Ф (5, open 5):**
|
||||
- [Ф][open] «Отображение»: Форматированный текст / Простой текст / Просмотр — P2
|
||||
- [Ф][open] «Расширение файла»: .md / .txt / Свой (+ поле) — P3
|
||||
- [Ф][open] «Папка заметок» (WebDAV picker) — P3
|
||||
- [Ф][open] секция «Мобильные приложения» (Android / iPhone) — P3
|
||||
- [Ф][open] секция «Комбинации клавиш» (в теме скрыта) — n-a? (уточнить — пока open) — P3
|
||||
|
||||
**Д (2, open 2):** [Д][open] полноэкранная модалка на мобильном (`_mobile-notes.css`) ·
|
||||
[Д][open] вёрстка секций/переключателей
|
||||
|
||||
---
|
||||
|
||||
## Сводка
|
||||
| Экран | Ф всего | Ф open | Ff% | Д всего | Д open | Fd% |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1. Список | 10 | 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** | **26** | **38.1%** | **21** | **21** | **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`,
|
||||
удаление, избранное, «Все заметки», диалог конфликта. (= итерация 1 плана)
|
||||
- **P2 — запись/организация:** создание, переименование, категории (создать/сменить/фильтр),
|
||||
автозаголовок, поиск, группировка, настройки отображения. (= итерация 2)
|
||||
- **P3 — добивка:** шаринг, подкатегории, удаление/переименование категорий, полноэкранный
|
||||
режим, расширение файла/папка, попиксельная вёрстка по `_mobile-notes.css`. (= итерация 3)
|
||||
|
||||
## Блокеры
|
||||
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 (шаринг/добивка вёрстки).
|
||||
@@ -0,0 +1,172 @@
|
||||
# Карта: Заметки
|
||||
|
||||
Приложение «Заметки» (`/apps/notes/`) на forbion.f7cloud.ru. Структура кнопок/меню снята
|
||||
**из кода** (метод mail/078): апстрим `nextcloud/notes` **v5.0.0** (`src/**/*.vue` — точная
|
||||
версия с forbion, канон README) + мобильная тема forbion
|
||||
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-notes.css`,
|
||||
`css/pages/app-notes/`). Живой мобильный web (Browser 1, `cloude`, mail/079) — только для
|
||||
визуальной сверки «как выглядит/порядок»; **строго read-only** (mail/073): ничего не
|
||||
создаётся/не меняется/не удаляется. Приватность: реальные заголовки/тексты заметок не
|
||||
фиксируются — только структура UI и дерево переходов.
|
||||
|
||||
> Статус визуальной сверки на живом forbion (Browser 1 `c9284989`, `cloude`, mail/079,
|
||||
> техника same-origin iframe 390px): **выполнена частично, 2026-07-12.** Мобильная вёрстка
|
||||
> подтверждена (`header__mobile`, `header__mobile-bottom`). **Подтверждено на живом сервере:**
|
||||
> пустое состояние Welcome (тексты 1:1 с кодом), кнопка «Новая заметка», поле поиска, «Создайте
|
||||
> образец заметки с помощью Markdown», навигация «Все заметки» (со счётчиком) / капшен
|
||||
> «Категории» / «Новая категория», «Меню настроек» (модалка открывается, секция «Мобильные
|
||||
> приложения» — Android / iPhone and iPad). **НЕ наблюдаемо read-only:** список строк-заметок,
|
||||
> меню заметки (⋮), редактор (rich/plain/preview), диалог конфликта, детальные секции настроек
|
||||
> «Общее» — у `cloude` заметок НЕТ, а создание/открытие = запись (запрещено mail/073). Эти узлы
|
||||
> остаются из кода (v5.0.0) — помечены `код-only` ниже. NB: при первом входе поверх приложения
|
||||
> всплывает глобальный онбординг-попап «Добро пожаловать в F7cloud!» (не notes-специфичный, не
|
||||
> закрывал — dismiss может писать флаг на сервер).
|
||||
|
||||
## Компоненты-источники (nextcloud/notes v5.0.0)
|
||||
`App.vue` (шелл: навигация категорий + настройки) · `NotesView.vue` (список + поиск + «Новая
|
||||
заметка») · `NotesList.vue`/`NoteItem.vue` (строка заметки + меню действий) ·
|
||||
`CategoriesList.vue` (навигация: Все заметки / Категории) · `Note.vue`→`NoteRich.vue`
|
||||
(rich-редактор @nextcloud/text) / `NotePlain.vue` (простой/preview) · `AppSettings.vue`
|
||||
(модалка настроек) · `Welcome.vue` (пустое состояние) · `ConflictSolution.vue` (конфликт
|
||||
версий) · `NoteShareSidebar.vue` (шаринг).
|
||||
|
||||
## Вход
|
||||
Шторка приложений (по «трём полоскам») → «Заметки», или прямой URL `/apps/notes/`.
|
||||
Оболочку (нижняя панель, шторка, бургер) описывает `shell.md` (зона лида) — здесь только
|
||||
специфика приложения. На мобильном (узкий вьюпорт) сначала показывается **список заметок**;
|
||||
выбор заметки открывает **редактор** поверх (`NcAppContent` show-details), возврат — «назад».
|
||||
|
||||
## Экран: Список заметок (главный)
|
||||
**Шапка списка (`content-list__search`) — ✔ кнопка и поле подтверждены на forbion:**
|
||||
- Кнопка «**Новая заметка**» (primary, иконка +) → создаёт заметку и открывает редактор
|
||||
(write — на forbion НЕ нажимаю).
|
||||
- Поле «**Искать заметки**» (`Search for notes`) → фильтрация списка по заголовку по мере
|
||||
ввода; крестик (trailing) очищает поиск.
|
||||
|
||||
**Тело:** список строк-заметок, сгруппированный. Группировка (`NotesView` groupedNotes):
|
||||
- если выбраны «Все заметки» и настроен режим — по **временным слотам** (заголовки-капшены
|
||||
через `NotesCaption`: сегодня/недавние/… — таймслоты) и/или по **категориям**;
|
||||
- избранные сортируются выше (favorite → вверх), затем по дате изменения.
|
||||
|
||||
**Строка заметки (`NoteItem` = `NcListItem`):**
|
||||
- Тап по строке → открыть заметку в редакторе.
|
||||
- Индикатор «расшарена» (значок ShareVariant, если у заметки есть шаринг).
|
||||
- Кнопка «**действия**» (⋮, меню строки) → см. «Меню заметки».
|
||||
|
||||
**Пустое состояние (нет заметок / `Welcome.vue`) — ✔ подтверждено на forbion (`cloude`,
|
||||
route `/apps/notes/welcome`):** заголовок «Заметки» + «Начните писать заметку, нажав «Новая
|
||||
заметка».» + кнопка «**Новая заметка**»; подсказки: «Записывайте свои мысли, ни на что не
|
||||
отвлекаясь.», «Поддержка языка разметки Markdown для оформления текста;», кнопка «**Создайте
|
||||
образец заметки с помощью Markdown**» (`CreateSampleButton` — write, не нажимаю), «Поддержка
|
||||
категорий;».
|
||||
|
||||
**Состояние загрузки:** «Загрузка …» (`Loading …`). **Пустой поиск в категории:** кнопка
|
||||
«**Найти во всех категориях**» (сбрасывает выбранную категорию).
|
||||
|
||||
## Меню заметки (⋮ в строке — `NoteItem` #actions) — `код-only`
|
||||
(У `cloude` заметок нет → строку и её меню на forbion read-only не наблюдал; из кода v5.0.0.)
|
||||
Порядок пунктов:
|
||||
- «**Добавить в избранное**» / «**Удалить из избранного**» (тумблер, звезда) → `PUT /favorite`.
|
||||
- «**Поделиться**» (Share) → открывает сайдбар шаринга (`NoteShareSidebar`, стандартный
|
||||
NC-шэринг). *(состав панели — общий NC sharing; детально не раскрывал — визуал(?))*
|
||||
- «**<Категория>**» / «**Изменить категорию**» → инлайн-выбор категории (мультиселект с
|
||||
возможностью ввести новую, taggable) → `PUT /category`.
|
||||
- «**Переименовать**» → инлайн-поле ввода нового заголовка → `PUT /title`.
|
||||
- —— разделитель ——
|
||||
- «**Удалить заметку**» (если не read-only) → удаление (`DELETE`) с возможностью отмены
|
||||
(`POST /notes/undo`). (write — не выполняю на forbion.)
|
||||
|
||||
## Экран: Редактор заметки — `код-only`
|
||||
(Открытие/создание заметки = запись → на forbion read-only не наблюдал; из кода v5.0.0.)
|
||||
Открывается тапом по заметке или «Новая заметка». Режим определяется настройкой Display
|
||||
(rich / plain / preview, см. Настройки).
|
||||
|
||||
**Заголовок:** формируется автоматически из первой строки (autotitle, `PUT /autotitle`);
|
||||
пустая заметка → «Новая заметка».
|
||||
|
||||
**Rich-режим (`NoteRich` → редактор @nextcloud/text):**
|
||||
- **Панель форматирования** (`.text-menubar` — в теме `_mobile-notes.css` она переносится на
|
||||
мобильном: `flex-wrap: wrap`). Инструменты (из списка горячих клавиш `AppSettings`):
|
||||
жирный, курсив, цитата, моноширинный, очистить стиль, список, нумерованный список,
|
||||
заголовок / крупный заголовок, вставить ссылку. *(точный набор иконок на узком экране —
|
||||
визуал(?))*
|
||||
- Тело — редактируемый markdown-контент; автосохранение (debounce) → `PUT /notes/{id}`
|
||||
с заголовком `If-Match: "<etag>"`.
|
||||
|
||||
**Plain-режим (`NotePlain`) — меню действий редактора (`NcActions`):**
|
||||
- «**Просмотр**» / «**Правка**» (тумблер preview⇄edit, подсказка «CTRL + /»).
|
||||
- «**Полный экран**» / «**Выйти из полноэкранного режима**» (тумблер).
|
||||
- Плейсхолдер пустого тела: «Write …» (или «Empty note» в preview).
|
||||
|
||||
**Read-only заметка:** пункт-индикатор «**Заметка только для чтения. Вы не можете её
|
||||
изменить.**» (`PencilOffOutline`), редактирование заблокировано.
|
||||
|
||||
**Ошибка сохранения:** пункт «**Сохранение не удалось. Нажмите, чтобы повторить.**»
|
||||
(`onManualSave`) — ручной повтор `PUT`.
|
||||
|
||||
## Диалог: Конфликт версий (`ConflictSolution`) — `код-only`
|
||||
Возникает, если заметка изменена в другой сессии (сервер вернул конфликт на `If-Match`):
|
||||
- Текст: «Заметка была изменена в другой сессии. Выберите, какую версию сохранить.»
|
||||
- Кнопка «**Использовать версию с сервера**».
|
||||
- Кнопка «**Использовать текущую версию**».
|
||||
> Прямое подтверждение моего плана (mail/071 п.2): конфликт разрешается ЯВНЫМ выбором, а не
|
||||
> молчаливой перезаписью. Нативный `NoteEditorScreen` обязан воспроизвести этот диалог.
|
||||
|
||||
## Навигация: Категории (левая шторка — `App.vue` + `CategoriesList`)
|
||||
На мобильном — за «бургером»/шторкой навигации приложения (не нижняя панель оболочки).
|
||||
✔ На forbion подтверждены: «Все заметки» (со счётчиком `0`), капшен «Категории», «Новая
|
||||
категория», кнопка «Меню настроек». Список конкретных категорий пуст (у `cloude` нет заметок).
|
||||
- «**Новая категория**» (`NcAppNavigationNew`, иконка папка+) → создание категории
|
||||
(drag-n-drop заметки на неё тоже вешает категорию). (write — не выполняю.)
|
||||
- «**Все заметки**» + счётчик (bubble) → сброс фильтра категории, весь список.
|
||||
- Капшен «**Категории**», далее список категорий:
|
||||
- «**Без категории**» (uncategorized) + счётчик.
|
||||
- Каждая категория: название (иконка папки, в теме заменена на серую
|
||||
`folder-gray.svg`) + счётчик; действия: «**Переименовать категорию**» (инлайн-правка),
|
||||
«**Удалить категорию**» → подтверждение (диалог «Удалить категорию» / «Удалить» /
|
||||
«Отмена»). (write — не выполняю.)
|
||||
- Подкатегории: имя вида `parent/child` разворачивается вложенно.
|
||||
- Внизу навигации: «**Настройки заметок**» (шестерёнка) → модалка настроек.
|
||||
|
||||
## Модалка: Настройки заметок (`AppSettings`)
|
||||
На мобильном тема раскрывает её на весь экран (`_mobile-notes.css`: modal-container height
|
||||
100%, скрыт заголовок modal-header). ✔ На forbion открыл модалку read-only и подтвердил секцию
|
||||
«**Мобильные приложения**» (подписи «Android», «iPhone and iPad» = `HelpMobile.vue`). Секции
|
||||
«Общее»/«Комбинации клавиш» детально не раскрывал (`код-only`, из v5.0.0). Секции:
|
||||
- **Общее** (`General`):
|
||||
- «**Отображение**» (Display): «Форматированный текст» (rich) / «Простой текст» (plain) /
|
||||
«Просмотр» (preview).
|
||||
- «**Расширение файла**» (для новых заметок): «.md» / «.txt» / «Свой» (+ поле «Своё
|
||||
расширение файла»).
|
||||
- «**Файлы**» → «**Папка заметок**» (`NcFormBoxButton`) → выбор папки хранения заметок
|
||||
(WebDAV picker). (write-настройка — не меняю.)
|
||||
- **Мобильные приложения** (`Mobile apps`) — ссылки/подсказки на приложения (в теме
|
||||
секция клавиш-шорткатов и это скрыты частично — визуал(?)).
|
||||
- **Комбинации клавиш** (`Shortcuts`) — справочный список (см. панель форматирования выше).
|
||||
В теме forbion секция `keyboard-shortcuts` **скрыта** (`display:none`) — на forbion её,
|
||||
вероятно, не видно. `визуал(?)`
|
||||
|
||||
## API-контур (для нативного модуля — подтверждает mail/071)
|
||||
Базовый путь `apps/notes/api/v1` (в вебе — `apps/notes/...`):
|
||||
- `GET /notes?pruneBefore=<ts>` — список (инкрементальный синк по времени; в нашем клиенте —
|
||||
`exclude=content` для лёгкого списка + ETag-коллекция).
|
||||
- `GET /notes/{id}` — заметка; `POST /notes` — создать; `PUT /notes/{id}` (тело `content`,
|
||||
заголовок `If-Match: "<etag>"`) — сохранить; `DELETE /notes/{id}`; `POST /notes/undo`.
|
||||
- `PUT /notes/{id}/favorite`, `/category`, `/title`, `/autotitle`;
|
||||
`PATCH /notes/category` (переименовать), `DELETE /notes/category` (удалить категорию).
|
||||
- Поля модели: `id, title, category, content, favorite, modified, etag, readonly`.
|
||||
|
||||
## Расхождения с нативным клиентом
|
||||
Модуль `feature/notes` ещё **не создан** (скелет заводит лид по mail/071) — поэтому полный
|
||||
список расхождений = весь функционал выше как первичный бэклог. Ключевые пункты к паритету
|
||||
при реализации плана 1→2→3 (mail/071):
|
||||
- Список: поиск по заголовку, группировка (таймслоты + категории), сортировка «избранное
|
||||
вверх», индикатор шаринга, пустое/загрузочное состояния.
|
||||
- Меню заметки: избранное, категория (с созданием новой), переименование, удаление с undo,
|
||||
шаринг.
|
||||
- Редактор: rich (панель форматирования) ↔ plain ↔ preview, полноэкранный режим, автосейв
|
||||
с `If-Match`, автозаголовок, read-only состояние.
|
||||
- **Диалог конфликта версий** (обязателен — не молчаливая перезапись).
|
||||
- Категории: навигация «Все/Без категории/по категориям» со счётчиками, CRUD категорий,
|
||||
вложенность `parent/child`.
|
||||
- Настройки: режим отображения, расширение файла, папка заметок.
|
||||
@@ -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б.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Карта: Уведомления (forbion, мобильная веб-версия)
|
||||
|
||||
Снято 2026-07-12. Метод по mail/078: структура берётся ИЗ КОДА
|
||||
(апстрим `nextcloud/notifications`, компоненты `NotificationsApp.vue` / `NotificationItem.vue` /
|
||||
`ActionButton.vue` / `IconNotification.vue`; на forbion версия 5.0.0-dev.0 — компоненты стабильны
|
||||
между версиями), живой мобильный web forbion (вьюпорт 390px, аккаунт `cloude`, строго read-only
|
||||
по mail/073) — для визуальной сверки размещения и порядка. Живой аккаунт `cloude` на момент съёмки
|
||||
имел **пустую ленту** (empty-state), поэтому структура элемента уведомления и действий снята с кода,
|
||||
а не с экрана. Пишущих кнопок («Скрыть», «Скрыть все», action-кнопки) НЕ нажимал — dismiss и
|
||||
выполнение действий это записи (DELETE / произвольный HTTP-метод), а аккаунт реальный рабочий.
|
||||
|
||||
Уведомления — НЕ отдельное приложение (нет пункта в шторке приложений и своего URL-роута), а
|
||||
глобальный **дропдаун-лента в шапке** (компонент `NotificationsApp`, монтируется в `#notifications`).
|
||||
|
||||
## Вход
|
||||
В мобильной вёрстке forbion иконка-триггер (колокольчик) живёт в **шторке приложений**, а не на
|
||||
плавающей нижней панели. Путь:
|
||||
- Нижняя панель шелла → `≡` («три полоски», `#open-header-menu-mobile`) открывает шторку
|
||||
`app-menu--mobile`.
|
||||
- В шторке — слот `#mobile-header-notifications` (класс `f7-mobile-menu-control-slot`,
|
||||
`mobile-bottom-slot--overlay`) с кнопкой-колокольчиком (`.notifications-button`,
|
||||
`aria-label="Уведомления"`, `aria-expanded`).
|
||||
- Нижняя плавающая панель (общая, зона лида, для ориентира): `<`/`>` (сайдбар-навигация,
|
||||
`#mobile-header-left-sidebar-button`) · конверт → Почта (`/apps/mail/`) · «карточки» → Задачи
|
||||
(`/apps/tasks/`) · камера → Конференции (`/apps/spreed/`) · `≡` → шторка приложений.
|
||||
|
||||
## Триггер (колокольчик) — `IconNotification`
|
||||
**Вид:** иконка «колокол». Индикатор состояния (`showDot`):
|
||||
- точка-бейдж показывается, когда `notifications.length !== 0` **или** когда браузерные разрешения
|
||||
ещё не запрошены (`webNotificationsGranted === null`);
|
||||
- значок-предупреждение (`showWarning`) — когда push-уведомления задросселированы политикой
|
||||
честного использования (`hasThrottledPushNotifications`).
|
||||
**Действие:** тап → раскрывает дропдаун-ленту (`aria-expanded` false→true), эмит `@opened → onOpen`
|
||||
(подгрузка/пометка ленты активной). Повторный тап или клик вне (кроме `.popover`) — закрывает.
|
||||
|
||||
## Экран: Лента уведомлений (дропдаун `NcHeaderMenu`, класс `header-menu`)
|
||||
Контейнер `.notification-container` внутри `.header-menu__content`. Тайтл шапки в мобильной
|
||||
теме — «Уведомления» (`.tooltip-header--notifications`). Два взаимоисключающих состояния:
|
||||
|
||||
### Состояние A — есть уведомления (`notifications.length > 0`) — СВЕРЕНО ЖИВЬЁМ у `cloude`
|
||||
**Тело:** список `<ul class="notification-wrapper">` из элементов `NotificationItem` (см. ниже),
|
||||
сверху вниз, новые (`notificationId > lastOpenMaxId`) помечены классом `notification--new`.
|
||||
Если push задросселирован — первым нештатным элементом идёт баннер «Push notifications might be
|
||||
unreliable» (fairUsePolicy, `key=-2016`).
|
||||
**Низ:** блок `.dismiss-all` — кнопка **«Скрыть все уведомления»** (tertiary, во всю ширину,
|
||||
иконка «×»). ⚠️ Пишущая: `onDismissAll` → `DELETE /ocs/v2.php/apps/notifications/api/v2/notifications`.
|
||||
|
||||
**Живая сверка 2026-07-12 (лента `cloude` = 3 уведомления `firstrunwizard`, скриншот 390px):**
|
||||
- `расхождение вер.:` кнопка **«Скрыть все уведомления»** на forbion (5.0.0-dev.0) показывается
|
||||
ВСЕГДА, в т.ч. при пустой ленте — в master-коде она под `v-if="notifications.length > 0"`.
|
||||
Нативный клиент должен ориентироваться на поведение forbion (кнопка всегда), не на master.
|
||||
- `расхождение вер.:` время в элементе отображается **относительно** («45 минут назад»,
|
||||
«49 минут назад»), а не в формате «время + длинная дата» из master `NcDateTime`.
|
||||
- Порядок в строке подтверждён: верхняя строка = [относительное время] … [× «Скрыть»];
|
||||
ниже слева иконка app, справа subject (жирнее) и, если есть, message (серым, второй строкой).
|
||||
- Наблюдённые элементы (все `data-app=firstrunwizard`, `objectType` user/app): у всех есть
|
||||
время + «Скрыть» + иконка + ссылка (`full-subject-link`, не внешняя), rich-subject НЕТ,
|
||||
action-кнопок НЕТ; у двух из трёх есть `message`. Примеры (приветственные, не приватные):
|
||||
«Добавьте данные в свой профиль…», «Рекомендованное приложение: Формы», «…: Распознавание».
|
||||
- `notification--new` в этой выборке не встретился (уведомления не новее `lastOpenMaxId`) —
|
||||
подсветку нового сверить, когда придёт свежее.
|
||||
|
||||
### Состояние B — пусто (empty-state `NcEmptyContent`) — тоже наблюдалось у `cloude` (до догрузки ленты)
|
||||
Три варианта текста (computed `emptyContentMessage`):
|
||||
- `webNotificationsGranted === null` → **«Запрос разрешений для показа уведомлений в браузере»**
|
||||
(именно это видел у `cloude` — иконка колокола `bell-outline-icon`);
|
||||
- задросселированы push → заголовок = subject fairUsePolicy + описание + кнопка «Contact
|
||||
Nextcloud GmbH ↗» (внешняя ссылка), иконка `icon-alert-outline`;
|
||||
- иначе → **«Нет уведомлений»** (`No notifications`). При переходе списка из >0 в 0 на ~5 сек
|
||||
показывается анимация inbox-zero (галочка + конфетти), затем обычный колокол.
|
||||
|
||||
## Элемент: `NotificationItem` (`<li class="notification">`, из кода)
|
||||
data-атрибуты: `data-id`, `data-timestamp`, `data-object-type`, `data-app`.
|
||||
**Шапка элемента (`.notification-heading`):**
|
||||
- время (`NcDateTime`, формат «время + длинная дата», `.notification-time`) — если есть timestamp;
|
||||
- кнопка **«Скрыть»** (`.notification-dismiss-button`, tertiary, иконка «×», `aria-label="Dismiss"`).
|
||||
⚠️ Пишущая: `onDismissNotification` → `DELETE …/api/v2/notifications/{id}`, затем `@remove` (убрать из ленты).
|
||||
**Субъект (заголовок), три варианта рендера:**
|
||||
- если `externalLink` → `<a target=_blank>` класс `full-subject-link external`, текст «{subject} ↗»;
|
||||
- иначе если есть `link` (useLink) → `<a>` на этот линк;
|
||||
- иначе — просто блок `.notification-subject` без ссылки.
|
||||
Внутри: опциональная иконка (`img.notification-icon`); если есть `subjectRich` — богатый рендер
|
||||
`NcRichText` с параметрами (см. Parameters ниже), иначе простой `.subject` с текстом subject.
|
||||
**Сообщение (`.notification-message`, если есть `message`):** богатый (`messageRich` + autolink)
|
||||
или простой текст; длинное сворачивается (`.message-container.collapsed` + градиент
|
||||
`.notification-overflow`), тап по сообщению — разворот (`onClickMessage`).
|
||||
**Действия (`.notification-actions`):**
|
||||
- если `notification.actions[]` непусто — ряд `ActionButton` по каждому action;
|
||||
- иначе, если есть `externalLink` — одна primary-кнопка «Contact Nextcloud GmbH ↗».
|
||||
|
||||
## Элемент: `ActionButton` (кнопка действия уведомления)
|
||||
Поля action (из API): `label`, `link`, `type`, `primary`.
|
||||
- `type === 'WEB'` (isWebLink) или `primary` → кнопка **primary**; для WEB это прямой `href` (ссылка).
|
||||
- прочие типы (GET/POST/DELETE/PUT) → кнопка **secondary**.
|
||||
**Клик (`onClickAction`):** эмит `notifications:action:execute` (приложение может отменить,
|
||||
`cancelAction`); для WEB — просто переход по ссылке; иначе ⚠️ пишущий вызов
|
||||
`axios({ method: action.type, url: action.link })`, при успехе — `@remove` (уходит из ленты) +
|
||||
эмит `notifications:action:executed`. Ошибка → тост «Failed to perform action».
|
||||
Пример типового уведомления с действиями — приглашение в Talk-беседу: subject + действия
|
||||
«Присоединиться»/«Отклонить» и т.п. (состав задаёт серверное приложение-источник).
|
||||
|
||||
## Параметры rich-subject/message (`Components/Parameters/`)
|
||||
`NcRichText` подставляет типизированные плейсхолдеры:
|
||||
- `UserParameter` — пользователь (аватар + имя, кликабельно);
|
||||
- `FileParameter` — файл (иконка + имя, ссылка на файл);
|
||||
- `DefaultParameter` — прочие (просто текст).
|
||||
`расхождение:` в нативном клиенте (`NotificationsRepository`/`NotificationsSheet`) сейчас берётся
|
||||
только плоский `subject`/`message` (API v2), rich-параметры и иконка app не разворачиваются —
|
||||
это мой бэклог (см. представление mail/070).
|
||||
|
||||
## API (из кода, все через OCS `apps/notifications/api/v2/notifications`)
|
||||
- `GET …/notifications` — лента (в нативе уже есть, `NotificationsRepository.load`, limit=50);
|
||||
- `DELETE …/notifications/{id}` — скрыть одно (в нативе есть `dismiss`);
|
||||
- `DELETE …/notifications` — скрыть все (в нативе НЕТ — бэклог «отметить/скрыть все»);
|
||||
- `{action.type} {action.link}` — выполнить действие уведомления (в нативе действий НЕТ — бэклог).
|
||||
Обновление ленты — по ETag (`lastETag`) и, если доступен, через notify_push (`@nextcloud/notify_push`).
|
||||
|
||||
## Стили (тема forbion)
|
||||
Отдельного `_mobile-notifications.css` в теме нет: на вебе лента — дроп в шапке
|
||||
(`css/components/_header.css`), мобильное размещение колокольчика — слот
|
||||
`#mobile-header-notifications` в `header__mobile-bottom`/шторке (тема
|
||||
`css/pages/pages-mobile/_mobile-common.css`). Для нативной ленты держим общий карточный стиль
|
||||
модулей (`_mobile-common.css` + токены designsystem) — согласовано с лидом (mail/070).
|
||||
|
||||
## Открытые вопросы (остаток после живой сверки 2026-07-12)
|
||||
Закрыто живьём: размещение строки, относительное время, «Скрыть все» всегда видна, состав
|
||||
простого уведомления (firstrunwizard) — см. «Состояние A». Осталось (нужны уведомления
|
||||
конкретных типов, которых у `cloude` сейчас нет):
|
||||
- (?) `notification--new`: подсветка/полоса для СВЕЖЕГО уведомления (в выборке все были старее
|
||||
`lastOpenMaxId`) — сверить, когда придёт новое.
|
||||
- (?) Точный порядок и подписи **action-кнопок** для источников с действиями (Talk-приглашение
|
||||
«Присоединиться/Отклонить», дедлайны Deck/Календаря) — у firstrunwizard действий нет.
|
||||
- (?) Сворачивание длинного `message` на ширине 390px (высота `.message-container.collapsed`,
|
||||
градиент `.notification-overflow`, разворот по тапу) — у наблюдённых message короткие.
|
||||
- (?) Богатый `subjectRich` (`NcRichText` с User/File-параметрами) — у firstrunwizard subject
|
||||
плоский; сверить на уведомлении с упоминанием пользователя/файла.
|
||||
@@ -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` (тип `Д`).
|
||||
@@ -0,0 +1,129 @@
|
||||
# Diff: Конференции (Talk) — итерация 1 (2026-07-13)
|
||||
Ff_app: **41.3%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
|
||||
|
||||
> Прогресс 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]`,
|
||||
Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве в след. проход — здесь Ф-функционал из кода; по
|
||||
сайдбару, пройденному live (Browser 1/cloude, iframe 390px, read-only), внесены реальные подписи.
|
||||
Натив-подписи ниже — из кода `TalkComponents.kt`/`TalkScreen.kt`/`TalkRoomInfoSheet.kt`.
|
||||
|
||||
## Экран 1: Список обсуждений — Ff 46.7% (8 open Ф / 15)
|
||||
Всплывающее меню строки беседы (mail/087) — веб `Conversation actions` ↔ натив:
|
||||
- [Ф][done] Поиск по списку · натив «Поиск» (`TalkComponents`).
|
||||
- [Ф][open] Фильтр списка (Непрочитанные/Упоминания/Встречи) · натив есть «Непрочитанные»/«Фильтр»,
|
||||
но без «Упоминания»/«Встречи» — частично.
|
||||
- [Ф][done] «+» FAB создания · натив «Новый чат».
|
||||
- [Ф][done] Открыть беседу (тап строки).
|
||||
- [Ф][done] В избранное / Убрать из избранного · натив «В избранное»/«Убрать из избранного».
|
||||
- [Ф][done] Пометить непрочитанным · натив «Пометить непрочитанным».
|
||||
- [Ф][done] Уровень уведомлений (Все/@/Выкл) · натив в листе «О комнате» (Все/@/Выкл.).
|
||||
- [Ф][open] **Отметить как прочитанное** (Mark as read) — в нативе нет отдельного действия.
|
||||
- [Ф][open] **Архивировать / Разархивировать** (Archive) — нет.
|
||||
- [Ф][open] **Покинуть обсуждение** (Leave conversation) — нет в меню списка.
|
||||
- [Ф][open] **Удалить обсуждение** (Delete conversation) — `deleteMessage` есть, удаления комнаты из UI нет.
|
||||
- [Ф][open] **Копировать ссылку** (Copy link) — нет.
|
||||
- [Ф][open] **Скрыть текст сообщения** (Hide message text, приватность превью) — нет.
|
||||
- [Ф][open] Метки **Важное / Чувствительное** (Important / Sensitive) — нет.
|
||||
- [Ф][open] Пункты сайдбара: **Архивные / Треды / Ожидающие приглашения** — нет.
|
||||
- [Д][—] Композиция строки (аватар/имя/превью/бейдж), зелёный FAB, поиск+фильтр — сверено live,
|
||||
выглядит 1:1 (детальный Fd — на устройстве).
|
||||
|
||||
## Экран 2: Меню «+» (создание) — Ff 33.3% (4 open Ф / 6)
|
||||
- [Ф][done] Создать обсуждение · натив «Новый чат» + «Создать группу» (личный/групповой/публичный).
|
||||
- [Ф][done] Новая личная заметка · есть комната «Личные заметки».
|
||||
- [Ф][open] **Начать встречу сейчас** (Start meeting now) — нет.
|
||||
- [Ф][open] **Присоединиться к открытым обсуждениям** (Join open conversations) — нет.
|
||||
- [Ф][open] **Проверить устройства** (Check devices / MediaSettings-превью) — нет (в нативе превью нет).
|
||||
- [Ф][open] **Позвонить на телефонный номер** (Call a phone number) — нет.
|
||||
- Мастер создания: [Ф][done] имя/тип/аватар; [Ф][open] описание, защита паролем, «Разрешить
|
||||
гостям по ссылке» — в нативе минимальный (имя+тип). *(учтено в open выше как часть «создать».)*
|
||||
|
||||
## Экран 3: Беседа + композер + меню сообщения — Ff 63.6% (8 open Ф / 22)
|
||||
Композер:
|
||||
- [Ф][done] Отправить сообщение · «Отправить».
|
||||
- [Ф][done] Эмодзи · «Смайлики».
|
||||
- [Ф][done] Вложение/шаринг файла · «Вложение».
|
||||
- [Ф][done] Голосовое сообщение · «Голосовое» + «Остановить запись».
|
||||
- [Ф][open] **Умный подборщик** (Smart picker) — нет.
|
||||
- [Ф][open] **Создать опрос** (Poll) — нет.
|
||||
- [Ф][open] **Создать тред** (Create a thread) — нет (тредов в нативе нет).
|
||||
- [Ф][open] **Тихая отправка** (Send without notification / silently) — нет.
|
||||
Меню сообщения (mail/087) — веб `MessageButtonsBar` ↔ натив (`MessageActions`):
|
||||
- [Ф][done] Ответить · «Ответить».
|
||||
- [Ф][done] Изменить · «Изменить».
|
||||
- [Ф][done] Удалить · «Удалить».
|
||||
- [Ф][done] Реакция · набор эмодзи (реакции есть).
|
||||
- [Ф][done] **Ответить лично** (Reply privately) — реализовано (a60fd78): открывает/создаёт 1:1 с автором.
|
||||
- [Ф][done] **Переслать в «Личные заметки»** — реализовано (59543a7); пересылка в произвольную
|
||||
комнату (выбор комнаты) — ещё open, вынес отдельным пунктом ниже.
|
||||
- [Ф][done] Переслать в произвольную комнату (выбор из списка) — реализовано (01198a4, ForwardTargetSheet).
|
||||
- [Ф][done] **Копировать сообщение** (Copy message → буфер) — реализовано (59543a7).
|
||||
- [Ф][done] **Копировать ссылку** (Copy message link → буфер) — реализовано (59543a7).
|
||||
- [Ф][done] **Напоминание** (Set reminder — сегодня/завтра/выходные/неделя) — реализовано (bbaaf7a);
|
||||
«своё» время (custom picker) — ещё open (мелочь, при добивке).
|
||||
- [Ф][open] **Отметить как непрочитанное** (per-message) — нет.
|
||||
- [Ф][open] **Перейти к файлу / к треду** (Go to file / thread) — нет.
|
||||
- [Ф][open] **Перевести** (Translate) — нет.
|
||||
- [Ф][open] **Скачать файл** (Download file из сообщения) — частично (вложения открываются, явного «скачать» нет).
|
||||
- [Ф][open] Напоминания/реакции доп. эмодзи (React with another emoji) — ограниченный набор.
|
||||
- [Д][—] Пузырь/автор/подложка чата — фон из макета (v0.5.114), детальный Fd — на устройстве.
|
||||
|
||||
## Экран 4: Активный звонок — Ff 91.7% (1 open Ф / 12; звонки НЕ инициировались)
|
||||
Реализация — `vendor/talk-android` v23 `CallActivity` (нативный WebRTC), покрывает панель звонка
|
||||
из кода апстрима: [Ф][done] микрофон, видео, размытие/вирт.фон, шаринг экрана, реакция, поднять
|
||||
руку, добавить участников, запись, breakout, полный экран, покинуть/завершить.
|
||||
- [Ф][open] **Скачать список присутствия** (Download attendance list) — проверить наличие в vendor UI.
|
||||
- [Д][n-a] Нагрузочная сверка звонка — запрещена (HPB боевой, mail/073/085); не тестируем.
|
||||
> Не-пройдено-live (звонки не инициирую). Отдельный трек — контрактная сверка v23 ↔ f7_talk
|
||||
> 22.0.12-f7.N + гашение EOL-баннера (mail/071).
|
||||
|
||||
## Экран 5: Детали беседы (правый сайдбар) — Ff 42.9% (4 open Ф / 7)
|
||||
Веб-табы ↔ натив лист «О комнате» (`TalkRoomInfoSheet`):
|
||||
- [Ф][done] Детали (тип, уровень уведомлений).
|
||||
- [Ф][done] Участники (список + «Добавить участника»).
|
||||
- [Ф][done] Поиск по сообщениям.
|
||||
- [Ф][open] **Общие ресурсы** (Shared items — файлы/медиа/ссылки беседы) — нет.
|
||||
- [Ф][open] **Треды** (Threads) — нет.
|
||||
- [Ф][open] **Комнаты обсуждения** (Breakout rooms) — нет.
|
||||
- [Ф][open] **Богатые действия участника** (промоут/права/лобби/бан/телефон) — в нативе только список.
|
||||
|
||||
## Экран 6: Настройки обсуждения — Ff 11.1% (8 open Ф / 9; 2 n-a)
|
||||
Веб `ConversationSettingsDialog` секции ↔ натив (нет выделенного экрана, частично лист «О комнате»):
|
||||
- [Ф][done] Базовая информация (тип/уведомления — в листе).
|
||||
- [Ф][open] Модерация, Права, Встреча, Комнаты обсуждения, Живая транскрипция, Опасная зона
|
||||
(архив/удаление/покинуть), Обзор настройки, Личное — нет.
|
||||
- [Ф][n-a] **Matterbridge**, **Боты** — серверно-администраторские, мобильно-неприменимо
|
||||
(обоснование: конфигурируются в вебе/сервере `f7_talk`, не мобильный сценарий).
|
||||
|
||||
## Экран 7: Личные настройки Talk — Ff 0% (8 open Ф / 8; 1 n-a)
|
||||
Веб `SettingsDialog` ↔ натив (выделенного экрана нет):
|
||||
- [Ф][open] Внешний вид/звуки, Компактный список, Устройства/Проверить, Размытие по умолчанию,
|
||||
Пропуск предпросмотра, Приватность (чтение/набор), Папка вложений, Настройки уведомлений — нет.
|
||||
- [Ф][n-a] Горячие клавиши (Shortcuts) — мобильно-неприменимо.
|
||||
|
||||
---
|
||||
## Сводка и приоритеты (прошу приоритизацию у лида)
|
||||
Крупнейшие функциональные дыры (кандидаты P1–P5):
|
||||
1. **Меню сообщения**: Переслать, Копировать, Напоминание, Ответить лично, Перейти к файлу/треду, Перевод.
|
||||
2. **Меню беседы (список)**: Архив, Покинуть, Удалить, Копировать ссылку, Отметить прочитанным.
|
||||
3. **Композер**: опрос, умный подборщик, тихая отправка, тред.
|
||||
4. **Детали**: Общие ресурсы (Shared items), богатые действия участника.
|
||||
5. **Треды** (сквозная фича, затрагивает список/беседу/детали) и **личные настройки Talk**.
|
||||
|
||||
Вопросы к лиду (b-dev-mobile):
|
||||
- Порядок P1–P5? Треды — целиком мой модуль или нужна навигация/core?
|
||||
- «Начать встречу сейчас» и «Предстоящие встречи» тянут интеграцию с Календарём (CalDAV) —
|
||||
кросс-модуль с b-mob-calendar? Веду я или согласуем общий вход?
|
||||
- Экран 4 (звонок): подтвердить, что панель vendor v23 считаем `done` без live-нагрузки, и
|
||||
выделить «список присутствия» как единственный open.
|
||||
- Fd (дизайн): гоню отдельным проходом на устройстве после ревью-сборки — версию/сборку по гейту (084) за тобой.
|
||||
|
||||
**Живая write-сверка `bmob-*`** (mail/085): у меня write на forbion пока НЕ выполняю — по
|
||||
CONVENTIONS §1 правки боевого хоста требуют явного разрешения владельца в рабочем чате роли;
|
||||
запрошу подтверждение. Функциональные дыры выше выявлены из кода, live-запись для их фиксации не нужна.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Карта: Конференции (Talk)
|
||||
|
||||
Приложение «Конференции» (`/apps/spreed/`) на forbion.f7cloud.ru. Структура кнопок/меню/переходов
|
||||
снята **из кода** (метод mail/078): апстрим `nextcloud/spreed` **v22.0.12** (`src/**/*.vue` — точная
|
||||
версия с forbion, совпадает с серверным форком `f7_talk 22.0.12-f7.N`) + мобильная тема forbion
|
||||
(`design-reference/forbion-theme/css/pages/pages-mobile/_mobile-spreed.css`, 4495 строк;
|
||||
`css/pages/app-spreed/`). Натив-эталон для сверки — мой `vendor/talk-android` v23 + `feature/talk`.
|
||||
Живой мобильный web (Browser 1, `cloude`, mail/079) — только визуальная сверка «как выглядит/порядок»;
|
||||
**строго read-only** (mail/073): ничего не создаётся/не меняется/не удаляется, **звонки не
|
||||
инициируются** (mail/073 п.4). Приватность: реальные имена собеседников/содержимое чатов не
|
||||
фиксируются — только структура UI и дерево переходов.
|
||||
|
||||
> Статус визуальной сверки на живом forbion: **левый сайдбар (список обсуждений) пройден
|
||||
> 2026-07-13** (Browser 1, `cloude`, same-origin iframe 390px, read-only — ни одной пишущей
|
||||
> операции). Реальные русские подписи forbion внесены (`сверено:`). **Экран беседы/чата
|
||||
> live НЕ проходился намеренно**: открытие комнаты выставляет read-marker на сервере (запись,
|
||||
> аналог `\Seen` у почты — см. mail.md) → узлы «Беседа», «Звонок», «Детали беседы», меню
|
||||
> сообщения достроены из кода апстрима, помечены `не-пройдено-live`.
|
||||
|
||||
## Компоненты-источники (nextcloud/spreed v22.0.12)
|
||||
Левый сайдбар (список): `LeftSidebar/LeftSidebar.vue` (+ `ConversationsList/`,
|
||||
`SearchConversationsResults/`, `OpenConversationsList/`, `InvitationHandler.vue`,
|
||||
`CallPhoneDialog/`). Создание: `NewConversationDialog/` (`NewConversationDialog.vue`,
|
||||
`NewConversationContactsPage.vue`, `NewConversationSetupPage.vue`). Топбар беседы/звонка:
|
||||
`TopBar/TopBar.vue` (+ `CallButton.vue`, `CallTime.vue`, `TopBarMenu.vue`, `TopBarMediaControls.vue`,
|
||||
`ReactionMenu.vue`, `TasksCounter.vue`). Чат: `ChatView.vue`, `MessagesList/` (+
|
||||
`MessagesGroup/Message/…/MessageButtonsBar.vue` — меню сообщения). Композер: `NewMessage/`. Звонок:
|
||||
`CallView/`, `MediaSettings/`. Правый сайдбар (детали): `RightSidebar/RightSidebar.vue`
|
||||
(+ `Participants/`, `SharedItems/`, `Threads/`, `SearchMessages/`, `BreakoutRooms/`,
|
||||
`LobbyStatus.vue`, `SipSettings.vue`). Настройки беседы: `ConversationSettings/ConversationSettingsDialog.vue`.
|
||||
Личные настройки: `SettingsDialog/SettingsDialog.vue`. Пусто-состояние: `EmptyView.vue`.
|
||||
|
||||
## Вход
|
||||
Нижняя плавающая панель оболочки → «Конференции», либо шторка приложений («три полоски»), либо
|
||||
прямой URL `/apps/spreed/`. Оболочку (нижняя панель, шторка, бургер) описывает `shell.md` (зона
|
||||
лида) — здесь только специфика приложения. На мобильном (узкий вьюпорт ~390px) тема
|
||||
(`_mobile-spreed.css`) показывает **сначала левый сайдбар на весь экран** (список обсуждений);
|
||||
выбор беседы разворачивает беседу на весь экран, правый сайдбар (детали) и модалки — тоже
|
||||
`height:100%`, «назад» возвращает к списку.
|
||||
|
||||
## Экран: Список обсуждений (левый сайдбар — `LeftSidebar`)
|
||||
Лендинг приложения на мобильном.
|
||||
**Шапка:**
|
||||
- Поле «**Поиск …**» (Search) → фильтрация списка + поиск людей/открытых обсуждений
|
||||
(`SearchConversationsResults`, `OpenConversationsList`).
|
||||
- Иконка-фильтр (ползунки, `Filter conversations by`) → меню фильтров: «**Непрочитанные**»
|
||||
(Unread), «**Упоминания**» (Mentions), «**Встречи**» (Meetings/Meeting conversations),
|
||||
«**Очистить фильтр**» (Clear filter/Clear filters). `сверено:` на forbion рядом с полем поиска.
|
||||
- Зелёная кнопка «**+**» → меню создания (см. ниже). `сверено:` FAB зелёный, справа от поиска.
|
||||
- (Над сайдбаром — общий NC-хедер оболочки: «Объединённый поиск», «Меню настроек»,
|
||||
«Уведомления» — это `shell.md`, не Talk.)
|
||||
|
||||
**Тело:** список обсуждений (`ConversationsList` → `Conversation`). Каждая строка: аватар/иконка,
|
||||
имя, превью последнего сообщения, бейдж непрочитанных, индикатор звонка «Call in progress».
|
||||
`сверено:` на forbion у `cloude` видны «Личные заметки» (Note to self, создано системой),
|
||||
«Давайте начнём!» (группа, бейдж 7), «Обновления приложения "Конференции"» (бейдж 57).
|
||||
|
||||
**Действия строки (свайп/⋯ — `Conversation actions`):**
|
||||
- «**Отметить как прочитанное / непрочитанное**» (Mark as read / unread).
|
||||
- «**Добавить в избранное / Убрать из избранного**» (Add to / Remove from favorites).
|
||||
- «**Уведомления**» (Notifications) → подменю: «Уведомлять о звонках» (Notify about calls),
|
||||
«@-упоминания» (@-mentions only), «Все сообщения» (All messages), «Выкл» (Off),
|
||||
«Игнорировать "Не беспокоить"» (Ignore "Do not disturb").
|
||||
- «**Настройки обсуждения**» (Conversation settings) → диалог настроек беседы (см. ниже).
|
||||
- «**Скрыть текст сообщения**» (Hide message text — приватность превью).
|
||||
- «**Архивировать / Разархивировать обсуждение**» (Archive / Unarchive conversation).
|
||||
- «**Копировать ссылку**» (Copy link).
|
||||
- «**Покинуть обсуждение**» (Leave conversation) → подтверждение «Do you really want to leave…»;
|
||||
предупреждение «You need to promote a new moderator before you can leave».
|
||||
- «**Удалить обсуждение**» (Delete conversation) → подтверждение «Do you really want to delete…».
|
||||
- Метки: «Важное обсуждение» (Important), «Чувствительное обсуждение» (Sensitive conversation).
|
||||
*(Все перечисленные — потенциально write; на live НЕ жму.)*
|
||||
|
||||
**Прочие пункты сайдбара (навигация/`TopBarMenu` списка):** «**Архивные обсуждения**»
|
||||
(Archived conversations / «You have no archived conversations»), «**Обсуждения-встречи**»
|
||||
(Meeting conversations), «**Треды**» (Threads / «No followed threads» / «Show more threads»),
|
||||
«**Ожидающие приглашения**» (Pending invitations, `InvitationHandler`), «**Настройки приложения**»
|
||||
(App settings) → личные настройки (см. «Настройки Talk»).
|
||||
|
||||
**Пусто-состояния:** «Обсуждения не найдены» (No conversations found), «Нет совпадений»
|
||||
(No matches found), «У вас нет непрочитанных упоминаний/сообщений».
|
||||
|
||||
## Экран: Меню «+» (создание) — `NewConversationDialog` + пункты
|
||||
Открывается зелёной «+». Пункты (`сверено:` порядок с live forbion):
|
||||
- «**Начать встречу сейчас**» (Start meeting now) → создаёт беседу и сразу входит в звонок.
|
||||
*(write — не жму.)*
|
||||
- «**Создать новое обсуждение**» (Create a new conversation) → мастер создания (см. ниже).
|
||||
- «**Присоединиться к открытым обсуждениям**» (Join open conversations) → список открытых
|
||||
(`OpenConversationsList`) → «Присоединиться к обсуждению» (Join conversation).
|
||||
- «**Проверить устройства**» (Check devices) → `MediaSettings` (превью камеры/микрофона,
|
||||
выбор устройств, размытие фона) — read-only-превью, без входа в звонок.
|
||||
- (Также доступно) «**Позвонить на телефонный номер**» (Call a phone number, `CallPhoneDialog`),
|
||||
«**Новая личная заметка**» (New personal note).
|
||||
|
||||
### Подэкран: Мастер «Создать новое обсуждение» (`NewConversationDialog`)
|
||||
Два шага:
|
||||
1. **Contacts page** (`NewConversationContactsPage`): «**Найти участников или телефонные номера**»
|
||||
(Search participants or phone numbers), «**Добавить участников**» (Add participants),
|
||||
«**Добавить телефонный номер**» (Add a phone number).
|
||||
2. **Setup page** (`NewConversationSetupPage`): поля «**Имя**» (Name / «Enter a name…»),
|
||||
«**Описание**» (Description), «**Изображение**» (Picture — аватар беседы); переключатели
|
||||
«**Видимость обсуждения**» (Conversation visibility), «**Разрешить гостям вход по ссылке**»
|
||||
(Allow guests to join via link → «Копировать ссылку»), «**Защита паролем**» (Password
|
||||
protection → «Ввести пароль»/«Копировать пароль»). Кнопки: «**Назад**», «**Создать
|
||||
обсуждение**» (Create conversation). *(write — мастер открываю без сохранения / на live не
|
||||
прохожу.)*
|
||||
|
||||
## Экран: Беседа (чат) — `ChatView` + `TopBar` + `NewMessage` *(не-пройдено-live)*
|
||||
**Шапка беседы (`TopBar`):**
|
||||
- «**Назад**» (Back) → список обсуждений (мобильный).
|
||||
- Имя беседы/аватар → «**Настройки обсуждения**» / «**Детали**» (правый сайдбар).
|
||||
- «**Начать звонок**» / «**Присоединиться к звонку**» (Start call / Join call, `CallButton`;
|
||||
«Start call silently» — тихий старт). При обновлении сервера: «Nextcloud Talk was updated,
|
||||
you cannot start or join a call».
|
||||
- «**Ещё действия**» (More actions, `TopBarMenu`): «Настройки обсуждения», «Действия обсуждения»,
|
||||
«Проверить устройства», «Открыть чат» (Open chat), полноэкранный режим (Full screen / F).
|
||||
**Тело:** лента сообщений (`MessagesList`); «Нет сообщений» (No messages), «Все сообщения
|
||||
истекли или были удалены», «Это обсуждение только для чтения» (read-only conversation),
|
||||
«Прокрутить вниз» (Scroll to bottom), «Кто-то печатает…» (Someone is typing…).
|
||||
**Композер (`NewMessage`):**
|
||||
- Поле «**Отправить сообщение**» (Post message / Send message); «Отправить» (Send).
|
||||
- «**Добавить эмодзи**» (Add emoji).
|
||||
- «**Поделиться файлами в обсуждении**» (Share files): «Поделиться из Файлов» (Share from Files),
|
||||
«Загрузить» (Add more files), «Создать и поделиться новым файлом» (Create and share a new file).
|
||||
- «**Умный подборщик**» (Smart picker).
|
||||
- «**Создать опрос**» (Create new poll, `PollViewer`).
|
||||
- «**Записать голосовое сообщение**» (Record voice message → «Завершить и отправить»/«Отменить»).
|
||||
- «**Создать тред**» (Create a thread / silently).
|
||||
- Тихая отправка: «Отправить без уведомления» (Send without notification / Send message silently).
|
||||
- Редактирование: «Изменить сообщение» (Edit message), «Отменить редактирование».
|
||||
|
||||
### Меню сообщения (`MessageButtonsBar`) *(не-пройдено-live)*
|
||||
Долгое нажатие/⋯ на сообщении:
|
||||
- «**Ответить**» (Reply) / «**Ответить лично**» (Reply privately).
|
||||
- «**Добавить реакцию**» (Add a reaction; «React with {emoji}», меню реакций).
|
||||
- «**Переслать сообщение**» (Forward message / «в "Личные заметки"» — Note to self).
|
||||
- «**Копировать сообщение**» / «**Копировать ссылку на сообщение**» (Copy message / message link).
|
||||
- «**Напоминание**» (Set reminder): «Сегодня позже», «Завтра», «В выходные», «На следующей
|
||||
неделе», «Задать своё…»; «Очистить напоминание».
|
||||
- «**Отметить как непрочитанное**» (Mark as unread).
|
||||
- «**Перейти к файлу**» (Go to file) / «**Перейти к треду**» (Go to thread).
|
||||
- «**Изменить сообщение**» (Edit message) / «**Изменить детали треда**».
|
||||
- «**Перевести**» (Translate).
|
||||
- «**Скачать файл**» (Download file).
|
||||
- «**Удалить**» (Delete).
|
||||
|
||||
## Экран: Активный звонок — `CallView` + `TopBar`/`bottom-bar` *(не-пройдено-live, звонки запрещены)*
|
||||
Нижняя панель звонка (тема `.bottom-bar-call-controls`, `.buttons-bar`):
|
||||
- «**Микрофон вкл/выкл**», «**Видео вкл/выкл**» (Disable video), «**Размытие фона**»
|
||||
(Disable background blur / Select virtual background).
|
||||
- «**Показать экран**» (Show your screen / Enable screensharing / Screensharing options).
|
||||
- «**Отправить реакцию**» (Send a reaction).
|
||||
- «**Поднять руку**» (Raise/lower hand).
|
||||
- «**Добавить участников**» (Add participants to this call).
|
||||
- «**Запись**» (Start/Stop recording; «Starting the recording», согласие на запись).
|
||||
- «**Комнаты обсуждения**» (Set up breakout rooms; «Back to main room»).
|
||||
- «**Полный экран**» (Full screen / Exit full screen).
|
||||
- «**Покинуть звонок**» (Leave call) / «**Завершить звонок для всех**» (End call for everyone).
|
||||
- «**Скачать список присутствия**» (Download attendance list).
|
||||
|
||||
## Экран: Детали беседы (правый сайдбар — `RightSidebar`) *(частично не-пройдено-live)*
|
||||
Табы (`RightSidebarContent`):
|
||||
- «**Детали**» (Details) — инфо о беседе, «Предстоящие встречи» (Upcoming meetings,
|
||||
`CalendarEventsDialog`).
|
||||
- «**Участники ({count})**» (Participants) — список; действия участника (`ParticipantItem`):
|
||||
«Повысить/понизить модератора» (Promote/Demote moderator), «Изменить права» (Edit permissions:
|
||||
Grant all / Remove all / default), «Переместить в лобби» (Move back to lobby), «Переместить в
|
||||
обсуждение» (Move to conversation), «Удалить участника» (Remove participant), «Забанить»
|
||||
(Also ban…), телефонные (Dial out / Mute / Hold / Hang up / Copy phone number). Строка поиска
|
||||
«Добавить участников».
|
||||
- «**Чат**» (Chat / Open chat) — на узком экране.
|
||||
- «**Общие ресурсы**» (Shared items, `SharedItems`) — файлы/медиа/ссылки беседы.
|
||||
- «**Треды**» (Threads in {name}).
|
||||
- «**Поиск сообщений**» (Search messages / Search in {name}).
|
||||
- «**Комнаты обсуждения**» (Breakout rooms), «**Лобби**» (LobbyStatus), «**SIP**» (SipSettings) —
|
||||
когда включены.
|
||||
|
||||
## Экран: Настройки обсуждения (`ConversationSettingsDialog`) *(write-разделы — не жму)*
|
||||
Секции (модалка на весь экран): «**Основная информация**» (Basic Info + `ConversationAvatarEditor`),
|
||||
«**Обзор настройки**» (Setup overview), «**Личное**» (Personal), «**Модерация**» (Moderation),
|
||||
«**Права**» (Permissions), «**Встреча**» (Meeting), «**Комнаты обсуждения**» (Breakout Rooms),
|
||||
«**Живая транскрипция**» (Live transcription), «**Боты**» (Bots), «**Matterbridge**»,
|
||||
«**Опасная зона**» (Danger zone: «Архивировать обсуждение», удаление/покидание).
|
||||
> Контрактно важно: разделы «Боты» и «Живая транскрипция» завязаны на серверный форк
|
||||
> `f7_talk 22.0.12-f7.N` (bot API /api/v1, транскрипция ячейки A) — предмет моей сверки, не вёрстки.
|
||||
|
||||
## Экран: Настройки Talk (личные — `SettingsDialog`)
|
||||
Открывается «Настройки приложения» (App settings) из сайдбара. Секции:
|
||||
- «**Внешний вид и звуки**» (Appearance & Sounds): «Компактный список обсуждений» (Compact
|
||||
conversations list), «Звуки уведомлений чата и звонков», «Проигрывать звуки при входе/выходе».
|
||||
- «**Устройства**» (Devices): «Проверить устройства» (Check devices), «Размывать фон камеры по
|
||||
умолчанию» (Blur camera background by default), «Пропускать предпросмотр устройства перед
|
||||
звонком» (Skip device preview), «Камера вкл/выкл», «Микрофон вкл/выкл».
|
||||
- «**Приватность**» (Privacy): «Отправлять уведомления о прочтении» (Send read receipts),
|
||||
«Делиться статусом набора» (Share typing status).
|
||||
- «**Вложения**» (Attachments): «Папка вложений» (Attachments folder → «Выбрать расположение»).
|
||||
- «**Уведомления**» (Notification settings).
|
||||
- «**Горячие клавиши**» (Shortcuts while in a call / in a chat) — не актуально на мобильном.
|
||||
|
||||
## Расхождения / бэклог (натив `feature/talk` ↔ живой forbion)
|
||||
Заполняется при функциональной сверке нативного клиента (задача №1 после карты). Кандидаты из
|
||||
беглого сопоставления кода `feature/talk` (Compose-UI на spreed OCS **/api/v4**) с апстримом v22:
|
||||
- `расхождение:` проверить наличие в нативе: **тредов** (Threads), **напоминаний по сообщению**
|
||||
(Set reminder), **опросов** (Poll), **умного подборщика** (Smart picker), **пересылки**
|
||||
(Forward), **комнат обсуждения** (Breakout rooms), **живой транскрипции**.
|
||||
- `расхождение:` состав меню создания «+» (в нативе — есть ли «Начать встречу сейчас» /
|
||||
«Присоединиться к открытым» / «Позвонить на номер»).
|
||||
- `расхождение:` секции «Настройки обсуждения» (модерация/права/лобби/SIP) — покрытие в нативе.
|
||||
- Звонок (натив = `vendor/talk-android` v23 CallActivity): сверить панель управления звонком
|
||||
(реакции, поднятая рука, запись, breakout, шаринг экрана) — отдельно от Compose-UI чата.
|
||||
- EOL-баннер «server almost EOL» (порог мажора 26 у v23 против сервера 22) — гасим в vendor
|
||||
(решение лида, mail/071); зафиксировать в `vendor/talk-android/README.F7CLOUD.md`.
|
||||
|
||||
Открытые (?) (доберу при функциональной сверке на устройстве / доп. live-проходе без записи):
|
||||
точные подписи меню сообщения и действий участника на forbion; поведение фильтра списка;
|
||||
состав правого сайдбара на узком вьюпорте (какие табы прячет тема).
|
||||
@@ -0,0 +1,110 @@
|
||||
# Diff: Задачи (Tasks) — итерация 1 (2026-07-13)
|
||||
|
||||
Ff_app: **24.6%** · Fd_app: **52.7%** (среднее по 8 экранам, невзвешенно) · n-a: 0
|
||||
|
||||
> Прогресс P1 (коммиты `23f7565`/`e405dad`/`bd56c4b`): умные коллекции выведены в панель
|
||||
> (агрегат + счётчики + открытие) + 3-state видимость (Скрыта/Видима/Авто) в настройках с
|
||||
> персистом в SharedPreferences. **P1 закрыт в коде.** Пункты `done` = сделано в коде, **ждёт
|
||||
> девайс-сверки** (тест-аккаунт — блокер лида); подтверждённого второго прогона ещё не было.
|
||||
|
||||
Сверка веб (`nextcloud/tasks v0.17.1` + тема) ↔ натив (`feature/tasks`: `TasksScreen`,
|
||||
`TasksComponents`, `TasksViewModel`, `TasksRepository`, `TasksSmartLists`). Метки: `[Ф|Д]`
|
||||
(функционал/дизайн) · `[open|done|n-a]`. Это первый прогон — счётчики честно низкие: натив
|
||||
покрывает базовый CRUD + группировку + движок умных списков (в коде, не в UI). Живую визуал-сверку
|
||||
на forbion (`bmob-*`, mail/085) добавлю следующим проходом — сейчас diff из кода.
|
||||
|
||||
Прошу приоритизацию: предлагаемый порядок P1→P5 в конце.
|
||||
|
||||
---
|
||||
|
||||
## Экран 1. Панель навигации (списки + умные коллекции) — Ff 28.6% · Fd 80%
|
||||
Ф (14, open 10):
|
||||
- [Ф][done] умные коллекции в панели (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) — выведены
|
||||
(`23f7565`), тап открывает агрегированный вид; ждёт девайс-сверки
|
||||
- [Ф][done] счётчики-бейджи на коллекциях
|
||||
- [Ф][done] открыть реальный список
|
||||
- [Ф][open] «Добавить список» (MKCALENDAR) — сейчас мёртвая кнопка
|
||||
- [Ф][open] выбор цвета при создании списка
|
||||
- [Ф][open] drag-сортировка списков
|
||||
- [Ф][open] drag задачи на коллекцию (retag priority/complete/today/week)
|
||||
- [Ф][open] открыть Корзину
|
||||
- [Ф][done] открыть Настройки
|
||||
- [Ф][open] «⋯» списка: поделиться · [Ф][open] изменить · [Ф][open] копировать ссылку ·
|
||||
[Ф][open] экспорт · [Ф][open] удалить/unshare
|
||||
Д (5, open 1): [Д][done] композиция (коллекции показаны) · [Д][done] иконки коллекций ·
|
||||
[Д][done] вид счётчика · [Д][done] активный пункт (фон) · [Д][open] порядок (в вебе коллекции
|
||||
выше списков и кнопки «Добавить»; в нативе кнопка «Добавить» сверху) · строка списка (40dp/иконка)
|
||||
|
||||
## Экран 2. Список задач — Ff 38.9% · Fd 75%
|
||||
Ф (18, open 11):
|
||||
- [Ф][done] создать задачу (быстрый ввод) · [Ф][open] создать несколько из вставки
|
||||
- [Ф][open] фильтр по меткам · [Ф][open] сброс фильтра
|
||||
- [Ф][open] выбор порядка сортировки (10 вариантов) · [Ф][done] направление ↑/↓
|
||||
- [Ф][open] «Загрузить завершённые» (по списку/всем)
|
||||
- [Ф][done] чекбокс выполнения · [Ф][done] открыть деталь · [Ф][done] поиск по тексту
|
||||
- [Ф][done] добавить подзадачу · [Ф][done] удалить задачу
|
||||
- [Ф][open] «⋯»: переключить важность (star) · [Ф][open] показать/скрыть подзадачи ·
|
||||
[Ф][open] показать/скрыть завершённые подзадачи
|
||||
- [Ф][open] индикатор pin · [Ф][open] индикатор «есть заметка»
|
||||
Д (8, open 2): [Д][done] группировка по сроку · [Д][done] зона завершённых · [Д][done] строка
|
||||
задачи · [Д][done] бейдж даты · [Д][done] пусто-состояние · [Д][done] чекбокс ·
|
||||
[Д][open] звезда приоритета в строке · [Д][open] иконки/вид меню сортировки-фильтра
|
||||
|
||||
## Экран 3. Деталь задачи — Ff 30% · Fd 66.7%
|
||||
Ф (20, open 14):
|
||||
- [Ф][done] правка заголовка · [Ф][done] срок · [Ф][open] дата начала · [Ф][open] «весь день»
|
||||
- [Ф][open] статус (Требует действия/В процессе/Отменено/Очистить) · [Ф][open] классификация
|
||||
- [Ф][done] приоритет · [Ф][open] %-выполнения (слайдер)
|
||||
- [Ф][open] метки · [Ф][open] локация · [Ф][open] URL · [Ф][open] смена списка задачи
|
||||
- [Ф][open] напоминания (до старта/до срока/дата-время) · [Ф][done] заметка/описание
|
||||
- [Ф][open] закрепить (pin) · [Ф][open] показать в Календаре · [Ф][open] показать в Deck
|
||||
- [Ф][open] экспорт · [Ф][done] удалить · [Ф][done] переключить выполнение
|
||||
Д (6, open 2): [Д][done] лейаут шита · [Д][open] вкладки Подробно/Заметки · [Д][done] pill-поля ·
|
||||
[Д][done] нативные пикеры · [Д][done] типографика · [Д][open] слайдеры приоритет/%
|
||||
|
||||
## Экран 4. Параметры задач (Настройки) — Ff 50% · Fd 100%
|
||||
Ф (2, open 1): [Ф][open] список по умолчанию (в UI есть, ещё не персистится) ·
|
||||
[Ф][done] видимость коллекций — **3-state (Скрыта/Видима/Авто) + персист SharedPreferences +
|
||||
влияет на панель** (`bd56c4b`); ждёт девайс-сверки
|
||||
Д (2, open 0): [Д][done] композиция шита · [Д][done] контрол 3-state (Скрыта/Видима/Авто)
|
||||
|
||||
## Экран 5. Правка/создание списка — Ff 0% · Fd 0% (экрана нет в нативе)
|
||||
Ф (3, open 3): [Ф][open] имя списка · [Ф][open] цвет · [Ф][open] сохранить (PROPPATCH)
|
||||
Д (1, open 1): [Д][open] модалка правки
|
||||
|
||||
## Экран 6. Шаринг списка — Ff 0% · Fd 0% (нет в нативе)
|
||||
Ф (4, open 4): [Ф][open] поиск пользователей/групп · [Ф][open] выдать доступ ·
|
||||
[Ф][open] список получателей · [Ф][open] отозвать
|
||||
Д (1, open 1): [Д][open] модалка шаринга
|
||||
|
||||
## Экран 7. Корзина — Ff 0% · Fd 0% (нет в нативе)
|
||||
Ф (4, open 4): [Ф][open] список удалённых · [Ф][open] восстановить · [Ф][open] очистить ·
|
||||
[Ф][open] сортировка по дате
|
||||
Д (1, open 1): [Д][open] модалка/таблица корзины
|
||||
|
||||
## Экран 8. Диалоги создания — Ff 50% · Fd 100%
|
||||
Ф (2, open 1): [Ф][done] создать задачу · [Ф][open] создать несколько из вставки
|
||||
Д (1, open 0): [Д][done] диалог создания
|
||||
|
||||
---
|
||||
|
||||
## Итог и предлагаемый приоритет
|
||||
Ff_app = (28.6+38.9+30+50+0+0+0+50)/8 = **24.6%**;
|
||||
Fd_app = (80+75+66.7+100+0+0+0+100)/8 = **52.7%**. Обе далеко от 98% — Задачи требуют нескольких
|
||||
итераций. Крупные функциональные дыры (по влиянию на Ff):
|
||||
|
||||
- **P1 — Умные коллекции в UI** (`TasksListNavView` + счётчики + 3-state видимость с персистом).
|
||||
✅ **СДЕЛАНО в коде** (`23f7565`/`e405dad`/`bd56c4b`: коллекции+счётчики+открытие + 3-state
|
||||
видимость + персист SharedPreferences). Ждёт девайс-сверки/подтверждающего прогона.
|
||||
- **P2 — Управление списками**: рабочее «Добавить список» + цвет, «⋯» списка (переименовать/цвет/
|
||||
удалить/экспорт/копировать ссылку), шаринг. Часть — core DAV (PROPPATCH/MKCALENDAR/share) —
|
||||
**согласую с тобой ДО** (образец у calendar). Мой модуль + core-точки.
|
||||
- **P3 — Богатая деталь**: старт-дата/весь день, статус, %-слайдер, метки, локация/URL, смена
|
||||
списка, напоминания, pin. Метки/напоминания — крупные подсистемы; уточню объём.
|
||||
- **P4 — Сортировка (10 порядков) + фильтр по меткам** вместо тумблера; «Загрузить завершённые».
|
||||
- **P5 — Корзина** (DAV trashbin) — навигация/core, вероятно твой ход.
|
||||
|
||||
Вопросы к тебе: (1) порядок P1–P5 ок? (2) метки (tags) и напоминания (VALARM) — беру целиком в
|
||||
`feature/tasks` или это кросс-модульное (метки шарятся с Календарём)? (3) Корзину относить в мой
|
||||
модуль или это core/навигация? (4) экраны 5/6/7 считать полноценными (тянут среднее в 0) или
|
||||
частью экрана навигации — уточни методику усреднения для «отсутствующих» экранов.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Карта: Задачи (Tasks)
|
||||
|
||||
Источник: апстрим `nextcloud/tasks v0.17.1` (`src/**/*.vue`, точная версия forbion) + тема
|
||||
`_mobile-tasks.css` (+ `app-tasks/forms/*`). Живой мобильный web (Browser 1) — для порядка/вида,
|
||||
помечено `визуал(?)` где не подтверждено на устройстве. Составил `b-mob-tasks` (mail/075/078/087).
|
||||
|
||||
Легенда: **[веб]** — как в мобильном вебе forbion; `расхождение:` — чего нет/иначе в нативе
|
||||
(вход в `tasks.diff.md`).
|
||||
|
||||
---
|
||||
|
||||
## Вход
|
||||
Нижняя панель / шторка приложений → «Задачи». Открывается **Панель навигации** (в вебе — левая
|
||||
`app-navigation`; в нативе — экран списков `TasksListNavView`).
|
||||
|
||||
---
|
||||
|
||||
## Экран: Панель навигации (списки + умные коллекции)
|
||||
Источник: `views/AppNavigation.vue`.
|
||||
|
||||
**Тело (сверху вниз):**
|
||||
1. **Умные коллекции** (виртуальные списки, каждый со счётчиком-бейджем `NcCounterBubble`):
|
||||
- «Важное» (starred, иконка Star) → `/collections/starred`
|
||||
- «Сегодня» (today, CalendarToday) → `/collections/today`
|
||||
- «На неделе» (week, CalendarWeek) → `/collections/week`
|
||||
- «Все» (all, CircleOutline) → `/collections/all`
|
||||
- «Текущие» (current, TrendingUp) → `/collections/current`
|
||||
- «Завершённые» (completed, Check) → `/collections/completed`
|
||||
- Видимость каждой управляется настройкой (Скрыта/Видима/Автоматически — см. модалку Настроек);
|
||||
«Автоматически» = показывать только если счётчик > 0.
|
||||
2. **Реальные списки** (календари VTODO), перетаскиваемые для ручной сортировки (long-press drag).
|
||||
У каждого — «⋯» меню (см. ниже).
|
||||
3. **«Add List…» / «Добавить список»** (иконка Plus) → инлайн-форма: поле «New list» +
|
||||
**выбор цвета** (`Colorpicker`); Enter/стрелка — создать (MKCALENDAR), Esc — отмена.
|
||||
4. **«Корзина» / «Trash bin»** (иконка Delete, если сервер поддерживает) → модалка Корзины.
|
||||
5. **Настройки** (шестерёнка в футере) → модалка «Параметры задач».
|
||||
|
||||
**Drag&drop:** задачу можно перетащить на коллекцию starred/completed/today/week — это
|
||||
проставляет приоритет=1 / 100% / срок сегодня / срок +6 дней соответственно.
|
||||
|
||||
`расхождение:` в нативе (`TasksListNavView`) показаны ТОЛЬКО реальные списки; умных коллекций,
|
||||
Корзины, drag-сортировки и drag-на-коллекцию нет; «Добавить список» — мёртвая кнопка (без обработчика).
|
||||
|
||||
### Всплывающее меню: «⋯» списка (в панели навигации)
|
||||
Источник: `components/AppNavigation/ListItemCalendar.vue`. Пункты по порядку:
|
||||
1. «Поделиться» (Share, если список расшариваем) → панель шаринга (пользователи/группы).
|
||||
2. «Изменить» (Edit) → форма правки списка: имя + цвет.
|
||||
3. «Копировать личную ссылку» (Copy private link → «Скопировано»/«Не удалось»).
|
||||
4. «Экспорт» (Export) → скачивание списка `.ics`.
|
||||
5. «Удалить» / «Отменить общий доступ» (Delete/Unshare, с обратным отсчётом → «Отмена»).
|
||||
6. (внутри правки) «Удалить все завершённые задачи» → модалка (см. `DeleteCompletedModal`).
|
||||
|
||||
`расхождение:` в нативе у списков НЕТ «⋯»-меню вовсе (нельзя переименовать/цвет/шарить/экспорт/
|
||||
удалить/копировать ссылку).
|
||||
|
||||
---
|
||||
|
||||
## Экран: Список задач (коллекция или реальный список)
|
||||
Источник: `components/HeaderBar.vue`, `views/AppContent/General.vue`, `components/TaskBody.vue`.
|
||||
|
||||
**Шапка (`HeaderBar`):**
|
||||
- **Поле быстрого ввода** «Add a task…» (иконка Plus): Enter/стрелка — создать задачу; вставка
|
||||
многострочного текста → диалог «Создать несколько задач». Скрыто для коллекции «Завершённые»
|
||||
и для read-only списка.
|
||||
- **«Фильтр»** (`FilterDropdown`, иконка воронки; активна — подсвечена) → меню (см. ниже).
|
||||
- **«Сортировка»** (`SortorderDropdown`, иконка текущего порядка + стрелка направления) → меню
|
||||
(см. ниже).
|
||||
|
||||
**Тело:**
|
||||
- Список задач (по выбранному порядку). Незавершённые сверху; завершённые — отдельной зоной.
|
||||
- Коллекция «Завершённые» / завершённые в списке: кнопка **«Загрузить завершённые задачи»**
|
||||
(`LoadCompletedButton`; варианты «…этого списка» / «…всех списков»).
|
||||
- Пустое состояние: плейсхолдер без задач. `визуал(?)`.
|
||||
|
||||
### Строка задачи + всплывающее меню «⋯»
|
||||
Источник: `components/TaskBody.vue`. Элементы строки:
|
||||
- **Чекбокс** выполнения (клик — переключить 0/100%); показывает % выполнения.
|
||||
- **Звезда** (если приоритет > 0).
|
||||
- **Заголовок** (клик — открыть деталь в сайдбаре).
|
||||
- **Дата** старта/срока (бейдж: Вчера/Сегодня/Завтра/дата; для завершённых — «Завершено …»).
|
||||
- Индикаторы: **закреплена** (Pin), **есть заметка** (клик → сайдбар, вкладка Заметки),
|
||||
**есть скрытые подзадачи** (SortVariant).
|
||||
- **Меню «⋯»** — пункты по порядку:
|
||||
1. «Добавить подзадачу» (Add subtask) → инлайн-поле ввода названия.
|
||||
2. «Показать/скрыть подзадачи» (если есть подзадачи).
|
||||
3. «Показать/скрыть завершённые подзадачи» (если есть завершённые подзадачи).
|
||||
4. «Удалить задачу» (Delete task, с обратным отсчётом отмены).
|
||||
5. «Переключить важность» (Toggle starred).
|
||||
|
||||
`расхождение:` нативное «⋯» (`TasksComponents.TasksRow`) имеет только «Добавить вложенную
|
||||
задачу» и «Удалить задачу»; нет toggle-звезды, show/hide подзадач, индикаторов pin/заметка/
|
||||
скрытые-подзадачи; звезды приоритета в строке нет.
|
||||
|
||||
### Выпадающее меню: Сортировка (`SortorderDropdown`)
|
||||
10 порядков (клик повторно — сменить направление ↑/↓):
|
||||
Актуальность (Relevance) · Дата начала · Дата выполнения (due) · Дата создания · Изменено ·
|
||||
Дата завершения · Приоритет · По алфавиту · Метки (Tags) · Вручную (Manually).
|
||||
|
||||
`расхождение:` в нативе только тумблер ↑/↓ по одному критерию (дата→имя), выбора из 10 порядков нет.
|
||||
|
||||
### Выпадающее меню: Фильтр (`FilterDropdown`)
|
||||
- «Фильтровать по меткам» (мультиселект тегов).
|
||||
- «Сбросить фильтр».
|
||||
|
||||
`расхождение:` в нативе кнопка «фильтр» открывает шит Настроек (не фильтр по тегам); тегов нет,
|
||||
фильтрации по тегам нет. Поиск по тексту в нативе есть (в VM), но отдельной строки поиска в вебе
|
||||
на этом экране нет — сверить `визуал(?)`.
|
||||
|
||||
---
|
||||
|
||||
## Экран: Деталь задачи (правый сайдбар)
|
||||
Источник: `views/AppSidebar.vue` (+ `components/AppSidebar/*`).
|
||||
|
||||
**Шапка сайдбара:** заголовок (редактируемый), подзаголовок (даты). Меню действий:
|
||||
- «Закрепить»/«Открепить» (Pin/Unpin).
|
||||
- «Показать в Календаре» (Show in Calendar).
|
||||
- «Показать в Deck» (Show in Deck).
|
||||
- «Изменить заголовок» (Edit summary).
|
||||
- «Экспорт» (Export).
|
||||
- «Удалить» (Delete).
|
||||
|
||||
**Вкладка «Подробно» (Details):**
|
||||
- **Классификация** (Select a classification): Public/Private/Confidential.
|
||||
- **Статус** (Select a status): Требует действия · Выполнено · В процессе · Отменено · Очистить.
|
||||
- **Дата начала** (Set start date) + **Дата выполнения** (Set due date), флажок **«Весь день»**.
|
||||
- **Приоритет** (слайдер): нет / низкий / средний / высокий (1–9).
|
||||
- **% выполнения** (слайдер/поле).
|
||||
- **Метки** (Select tags).
|
||||
- **Расположение** (Set a location), **URL** (Set a URL).
|
||||
- **Список** (переключение календаря задачи).
|
||||
- **Напоминания** (`AlarmList`): меню «Добавить напоминание» → «До начала задачи» / «До срока» /
|
||||
«Выбрать дату и время»; список напоминаний, «Нет напоминаний» — пусто.
|
||||
|
||||
**Вкладка «Заметки» (Notes):** многострочная заметка (Markdown).
|
||||
|
||||
`расхождение:` натив (`TaskDetailSheet`) — только заголовок, срок, приоритет, описание, toggle
|
||||
выполнения, удалить. Нет: старт-даты/всего дня, статуса (кроме bool), классификации, %-слайдера,
|
||||
меток, локации, URL, смены списка, напоминаний, Pin, «Показать в Календаре/Deck», экспорта,
|
||||
разделения на вкладки Подробно/Заметки.
|
||||
|
||||
---
|
||||
|
||||
## Модалка: Параметры задач (Настройки)
|
||||
Источник: `components/AppNavigation/AppNavigationSettings.vue`.
|
||||
- «Список по умолчанию» (Default list) — селект.
|
||||
- «Видимость умных коллекций» (Visibility of Smart Collections) — по каждой коллекции 3 варианта:
|
||||
**Скрыта / Видима / Автоматически**.
|
||||
|
||||
`расхождение:` в нативе (`TasksSettingsSheet`) видимость — только 2 варианта (Показывать/Скрывать),
|
||||
не персистится и ни на что не влияет; «список по умолчанию» не персистится.
|
||||
|
||||
## Модалка: Правка/создание списка
|
||||
Источник: `ListItemCalendar.vue` (форма) + тема `_tasks-list-edit-mobile.css`.
|
||||
- Поле «Имя списка» + **Colorpicker**. Сохранить/Отмена. `расхождение:` нет в нативе.
|
||||
|
||||
## Модалка: Шаринг списка
|
||||
Источник: тема `_tasks-list-shares-mobile.css` + `ListItemCalendar` share.
|
||||
- Поиск пользователей/групп, выдача доступа, список получателей. `расхождение:` нет в нативе.
|
||||
|
||||
## Модалка: Удалить завершённые
|
||||
Источник: `components/DeleteCompletedModal.vue`.
|
||||
- «Удалить все завершённые задачи» → подтверждение, отчёт «Удалено из списка …».
|
||||
- В нативе аналог есть: массовое удаление завершённых в зоне «Завершённые» (`deleteCompletedTasks`).
|
||||
|
||||
## Модалка: Фильтр по тегам
|
||||
Источник: тема `_tasks-filter-popover-mobile.css` + `FilterDropdown`. `расхождение:` нет в нативе.
|
||||
|
||||
## Экран: Корзина (Trash bin)
|
||||
Источник: `components/AppNavigation/Trashbin.vue` + тема `_tasks-trash-modal-mobile.css`.
|
||||
- Таблица удалённых (имя, дата удаления, сортировка), «Восстановить», «Очистить корзину»,
|
||||
пусто-состояние «Нет удалённых…». `расхождение:` нет в нативе.
|
||||
|
||||
## Диалоги создания
|
||||
Источник: `TaskCreateDialog.vue` (Создать задачу: поле «Краткое описание», Отмена/Создать),
|
||||
`CreateMultipleTasksDialog.vue` (из вставленного текста: «Создать N задач», Отмена/Создать).
|
||||
`расхождение:` быстрый ввод в нативе есть; массового создания из вставки — нет.
|
||||
|
||||
---
|
||||
|
||||
## Сводка всплывающих меню (mail/087) — веб ↔ натив
|
||||
| Меню | Веб (пунктов) | Натив |
|
||||
|---|---|---|
|
||||
| «⋯» списка (навигация) | 6 (share/edit/copy-link/export/delete/del-completed) | нет меню |
|
||||
| «⋯» задачи | 5 (subtask/show-hide/show-hide-closed/delete/star) | 2 (subtask/delete) |
|
||||
| Сортировка | 10 порядков + направление | тумблер ↑/↓ |
|
||||
| Фильтр | по тегам + сброс | нет (кнопка ведёт в Настройки) |
|
||||
| Создание «+» | задача / из вставки | задача |
|
||||
| Действия сайдбара | 6 (pin/calendar/deck/edit/export/delete) | 2 (toggle/delete) |
|
||||
| Напоминания | 3 (до старта/до срока/дата-время) | нет |
|
||||
| Настройки: видимость коллекций | 3 (скрыта/видима/авто) ×6 | 2 (показ/скрыть), неактивно |
|
||||
@@ -32,6 +32,7 @@ dependencies {
|
||||
implementation composeBom
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.material.icons.extended
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.activity.compose
|
||||
|
||||
@@ -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 -> onPick("%02d:%02d".format(hh, mm)) }, h, mi, true, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, y, m, d -> onPick(LocalDate.of(y, m + 1, d)) }, current.year, current.monthValue - 1, current.dayOfMonth, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, y, m, d -> 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 -> "Никогда" RecurrenceRule.EndType.UNTIL -> "До даты" RecurrenceRule.EndType.COUNT -> "После 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 -> 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: () -> Unit, onDraftChange: (CalendarEventDraft) -> Unit, onSave: () -> Unit, onDelete: () -> Unit, onSearchAttendees: (String) -> Unit, onAddAttendee: (CalendarAttendeeSuggestion) -> Unit, onRemoveAttendee: (String) -> Unit, onSearchLocations: (String) -> Unit, onApplyLocation: (CalendarLocationSuggestion) -> Unit, onOpenTalkPicker: () -> Unit = {}, onPickDeviceAttachment: () -> Unit = {}, onPickFilesAttachment: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, onOpenFreeBusy: () -> 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) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> 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<CalendarEventItem>, viewMode: CalendarViewMode, onViewMode: (CalendarViewMode) -> Unit, onEventClick: (CalendarEventItem) -> Unit, modifier: Modifier = Modifier, )</ID>
|
||||
<ID>LongMethod:CalendarExtendedUi.kt$@Composable fun CalendarFullEventEditorDialog( state: CalendarUiState, onDismiss: () -> Unit, onDraftChange: (CalendarEventDraft) -> Unit, onSave: () -> Unit, onDelete: () -> Unit, onSearchAttendees: (String) -> Unit, onAddAttendee: (CalendarAttendeeSuggestion) -> Unit, onRemoveAttendee: (String) -> Unit, onSearchLocations: (String) -> Unit, onApplyLocation: (CalendarLocationSuggestion) -> Unit, onOpenTalkPicker: () -> Unit = {}, onPickDeviceAttachment: () -> Unit = {}, onPickFilesAttachment: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, onOpenFreeBusy: () -> Unit = {}, )</ID>
|
||||
<ID>LongMethod:CalendarExtendedUi.kt$@Composable fun CalendarRepeatModal( initial: RecurrenceRule, onDismiss: () -> Unit, onApply: (RecurrenceRule) -> Unit, onClear: () -> 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) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> 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 -> 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() -> Text("Папка пуста", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$state.filesPickerLoading -> Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$state.talkPickerLoading -> Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$state.talkRooms.isEmpty() -> 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 -> st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -> 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 -> st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.YEAR -> 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<CalendarShareeSuggestion></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: () -> Unit, )</ID>
|
||||
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarNavButton(text: String, onClick: () -> 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>
|
||||
|
||||
@@ -25,6 +25,19 @@ data class CalendarLocationSuggestion(
|
||||
val address: String,
|
||||
)
|
||||
|
||||
data class CalendarTalkRoom(
|
||||
val token: String,
|
||||
val displayName: String,
|
||||
val callUrl: String,
|
||||
)
|
||||
|
||||
data class CalendarShareeSuggestion(
|
||||
val displayName: String,
|
||||
/** DAV-принципал: principal:principals/users/<id> или groups/<id>. */
|
||||
val principal: String,
|
||||
val isGroup: Boolean,
|
||||
)
|
||||
|
||||
data class CalendarUserSettings(
|
||||
val timezone: String = "automatic",
|
||||
val showWeekends: Boolean = true,
|
||||
@@ -106,6 +119,105 @@ class CalendarApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Email текущего пользователя (для ORGANIZER в iTIP free-busy). */
|
||||
fun getUserEmail(session: AuthSession): String {
|
||||
val client = authedClient(session)
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
|
||||
val request = Request.Builder().url(url).get().applyOcsJson().build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val json = parseJsonObject(response.body?.string().orEmpty(), "user info")
|
||||
json.optJSONObject("ocs")?.optJSONObject("data")?.optString("email").orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Поиск пользователей/групп для шаринга календаря (OCS sharees). */
|
||||
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> {
|
||||
if (query.trim().length < 2) return emptyList()
|
||||
val client = authedClient(session)
|
||||
val q = java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v1.php/apps/files_sharing/api/v1/sharees" +
|
||||
"?format=json&itemType=calendar&perPage=10&search=$q"
|
||||
val request = Request.Builder().url(url).get().applyOcsJson().build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val json = parseJsonObject(response.body?.string().orEmpty(), "sharees")
|
||||
val data = json.optJSONObject("ocs")?.optJSONObject("data") ?: return emptyList()
|
||||
val out = mutableListOf<CalendarShareeSuggestion>()
|
||||
fun collect(arrName: String, isGroup: Boolean) {
|
||||
val exactWrap = data.optJSONObject("exact")?.optJSONArray(arrName) ?: JSONArray()
|
||||
val plain = data.optJSONArray(arrName) ?: JSONArray()
|
||||
for (arr in listOf(exactWrap, plain)) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val item = arr.optJSONObject(i) ?: continue
|
||||
val id = item.optJSONObject("value")?.optString("shareWith").orEmpty()
|
||||
if (id.isBlank()) continue
|
||||
val kind = if (isGroup) "groups" else "users"
|
||||
out += CalendarShareeSuggestion(
|
||||
displayName = item.optString("label").ifBlank { id },
|
||||
principal = "principal:principals/$kind/$id",
|
||||
isGroup = isGroup,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
collect("users", isGroup = false)
|
||||
collect("groups", isGroup = true)
|
||||
out.distinctBy { it.principal }
|
||||
}
|
||||
}
|
||||
|
||||
/** Существующие беседы Talk (для выбора комнаты в событии). */
|
||||
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> {
|
||||
val client = authedClient(session)
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
|
||||
val request = Request.Builder().url(url).get().applyOcsJson().build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val json = parseJsonObject(response.body?.string().orEmpty(), "list talk rooms")
|
||||
if (!isOcsSuccess(json.ocsMeta())) return emptyList()
|
||||
val data = json.optJSONObject("ocs")?.optJSONArray("data") ?: JSONArray()
|
||||
val out = mutableListOf<CalendarTalkRoom>()
|
||||
for (i in 0 until data.length()) {
|
||||
val room = data.optJSONObject(i) ?: continue
|
||||
val token = room.optString("token")
|
||||
if (token.isBlank()) continue
|
||||
val name = room.optString("displayName").ifBlank { room.optString("name") }
|
||||
out += CalendarTalkRoom(
|
||||
token = token,
|
||||
displayName = name.ifBlank { "Беседа" },
|
||||
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
|
||||
)
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/** Создать беседу заданного типа (2 — групповая/публичная, 3 — публичная по ссылке). */
|
||||
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom {
|
||||
val client = authedClient(session)
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
|
||||
val payload = JSONObject()
|
||||
.put("roomType", roomType)
|
||||
.put("roomName", roomName.trim().ifBlank { "Встреча" })
|
||||
.put("readOnly", 0)
|
||||
.put("listable", 0)
|
||||
.toString()
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||
.applyOcsJson()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
|
||||
if (!isOcsSuccess(json.ocsMeta())) error("Не удалось создать беседу Talk")
|
||||
val token = json.optJSONObject("ocs")?.optJSONObject("data")?.optString("token").orEmpty()
|
||||
if (token.isBlank()) error("Не удалось создать беседу Talk")
|
||||
return CalendarTalkRoom(
|
||||
token = token,
|
||||
displayName = roomName.trim().ifBlank { "Встреча" },
|
||||
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createTalkRoom(session: AuthSession, roomName: String): String {
|
||||
val client = authedClient(session)
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
|
||||
|
||||
@@ -23,26 +23,26 @@ 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.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.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.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
|
||||
@@ -77,7 +77,6 @@ import java.util.Locale
|
||||
private val weekDayLabels = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
|
||||
private val dayTitleFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))
|
||||
private val listDayFormatter = DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.forLanguageTag("ru"))
|
||||
private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)
|
||||
private val dayHeaderFormatter = DateTimeFormatter.ofPattern("EE dd.M.yyyy", Locale.forLanguageTag("ru"))
|
||||
|
||||
private fun viewModeIconUrl(base: String, mode: CalendarViewMode): String = when (mode) {
|
||||
@@ -865,6 +864,7 @@ fun CalendarNavigationSidebar(
|
||||
onOpenTasks: () -> Unit,
|
||||
onCreateBook: () -> Unit,
|
||||
onSyncNow: () -> Unit,
|
||||
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
|
||||
) {
|
||||
val base = session.serverUrl.trimEnd('/')
|
||||
val (myCalendars, deckCalendars) = remember(state.calendars) {
|
||||
@@ -916,7 +916,7 @@ fun CalendarNavigationSidebar(
|
||||
onAdd = onCreateBook,
|
||||
)
|
||||
myCalendars.forEach { cal ->
|
||||
CalendarSidebarCalendarRow(cal, onToggleCalendar)
|
||||
CalendarSidebarCalendarRow(cal, onToggleCalendar, onEditCalendar)
|
||||
}
|
||||
if (deckCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -1006,6 +1006,7 @@ private fun CalendarSidebarCaption(
|
||||
private fun CalendarSidebarCalendarRow(
|
||||
cal: CalendarBookItem,
|
||||
onToggleCalendar: (String) -> Unit,
|
||||
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
|
||||
) {
|
||||
val accent = calendarAccentColor(cal)
|
||||
Row(
|
||||
@@ -1029,7 +1030,14 @@ private fun CalendarSidebarCalendarRow(
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Подписки редактировать нельзя (PROPPATCH/шаринг не применимы)
|
||||
if (onEditCalendar != null && !cal.isSubscription) {
|
||||
IconButton(onClick = { onEditCalendar(cal) }, modifier = Modifier.size(28.dp)) {
|
||||
Text("✎", color = F7Colors.TextSecondary, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1217,23 +1225,26 @@ fun CalendarDatePickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = selectedDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val millis = state.selectedDateMillis ?: return@TextButton
|
||||
val day = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
onConfirm(day)
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Отмена") }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = state)
|
||||
// Нативный DatePickerDialog вместо material3.DatePicker: material3-версия (BOM 2025.02)
|
||||
// падала при открытии, а платформенный диалог стабилен и сам локализуется под системную
|
||||
// локаль (русские месяцы). Показываем один раз, пока композабл присутствует в дереве.
|
||||
val context = LocalContext.current
|
||||
val currentOnConfirm by rememberUpdatedState(onConfirm)
|
||||
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||
DisposableEffect(Unit) {
|
||||
val dialog = android.app.DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, dayOfMonth ->
|
||||
currentOnConfirm(LocalDate.of(year, month + 1, dayOfMonth))
|
||||
},
|
||||
selectedDay.year,
|
||||
selectedDay.monthValue - 1,
|
||||
selectedDay.dayOfMonth,
|
||||
)
|
||||
// onDismiss закрывает и по «Отмена», и после выбора (диалог сам себя закрывает).
|
||||
dialog.setOnDismissListener { currentOnDismiss() }
|
||||
dialog.show()
|
||||
onDispose { dialog.setOnDismissListener(null); dialog.dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ data class CalendarTaskItem(
|
||||
data class CalendarEventDraft(
|
||||
val title: String = "",
|
||||
val date: java.time.LocalDate = java.time.LocalDate.now(),
|
||||
/** Дата окончания; null = совпадает с датой начала (однодневное событие). */
|
||||
val endDate: java.time.LocalDate? = null,
|
||||
val startTime: String = "10:00",
|
||||
val endTime: String = "11:00",
|
||||
val allDay: Boolean = false,
|
||||
@@ -82,9 +84,17 @@ data class CalendarEventDraft(
|
||||
val classification: EventClassification = EventClassification.PUBLIC,
|
||||
val attendees: List<CalendarAttendeeItem> = emptyList(),
|
||||
val attendeeQuery: String = "",
|
||||
val reminderMinutes: Int = 15,
|
||||
/** Напоминания в минутах до начала (0 = во время начала); поддерживается несколько. */
|
||||
val reminders: List<Int> = listOf(15),
|
||||
val addTalkRoom: Boolean = false,
|
||||
val talkRoomUrl: String = "",
|
||||
val attachments: List<CalendarAttachmentItem> = emptyList(),
|
||||
)
|
||||
|
||||
data class CalendarAttachmentItem(
|
||||
val fileName: String,
|
||||
val url: String,
|
||||
val mimeType: String = "",
|
||||
)
|
||||
|
||||
data class CalendarCreateBookDraft(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
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
|
||||
@@ -104,13 +110,111 @@ class CalendarRepository(
|
||||
|
||||
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
|
||||
|
||||
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> = apiClient.listTalkRooms(session)
|
||||
|
||||
private fun davUserId(session: AuthSession): String =
|
||||
session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||
|
||||
// --- Управление календарём ---
|
||||
fun updateCalendarProps(session: AuthSession, calendarHref: String, displayName: String?, color: String?) {
|
||||
CalDavClient.updateCalendarProps(davClient(session), calendarHref, displayName, color)
|
||||
}
|
||||
|
||||
fun deleteCalendar(session: AuthSession, calendarHref: String) {
|
||||
CalDavClient.deleteCalendar(davClient(session), calendarHref)
|
||||
}
|
||||
|
||||
fun listCalendarShares(session: AuthSession, calendarHref: String): List<CalDavClient.DavSharee> =
|
||||
CalDavClient.listCalendarShares(davClient(session), calendarHref)
|
||||
|
||||
fun shareCalendar(session: AuthSession, calendarHref: String, principal: String, writable: Boolean) {
|
||||
CalDavClient.shareCalendar(davClient(session), calendarHref, principal, writable)
|
||||
}
|
||||
|
||||
fun unshareCalendar(session: AuthSession, calendarHref: String, principal: String) {
|
||||
CalDavClient.unshareCalendar(davClient(session), calendarHref, principal)
|
||||
}
|
||||
|
||||
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> =
|
||||
apiClient.searchSharees(session, query)
|
||||
|
||||
/** Занятость участников (email → интервалы) за диапазон. */
|
||||
fun freeBusy(
|
||||
session: AuthSession,
|
||||
attendeeEmails: List<String>,
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
): Map<String, List<CalDavClient.BusyPeriod>> {
|
||||
val organizer = apiClient.getUserEmail(session)
|
||||
if (organizer.isBlank()) error("У пользователя не задан email — занятость недоступна")
|
||||
return CalDavClient.freeBusy(
|
||||
davClient(session),
|
||||
session.serverUrl,
|
||||
davUserId(session),
|
||||
organizer,
|
||||
attendeeEmails,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
)
|
||||
}
|
||||
|
||||
private fun davClient(session: AuthSession): OkHttpClient =
|
||||
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
|
||||
/** Загрузка файла с устройства в папку /Calendar и возврат ATTACH-элемента (WebDAV-URL). */
|
||||
fun uploadAttachment(
|
||||
session: AuthSession,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
bytes: ByteArray,
|
||||
): CalendarAttachmentItem {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val userId = davUserId(session)
|
||||
// Папка для вложений календаря; создаём best-effort (уже существует → игнорируем).
|
||||
runCatching { DavClient.mkcol(client, davFolderUrl(session.serverUrl, userId, "Calendar")) }
|
||||
val safeName = sanitizeFileName(fileName)
|
||||
val fileUrl = davFileUrl(session.serverUrl, userId, "Calendar/$safeName")
|
||||
val media = mimeType.ifBlank { "application/octet-stream" }.toMediaType()
|
||||
DavClient.put(client, fileUrl, bytes.toRequestBody(media))
|
||||
return CalendarAttachmentItem(fileName = safeName, url = fileUrl, mimeType = mimeType)
|
||||
}
|
||||
|
||||
/** Список файлов/папок в директории (для пикера «Добавить из файлов»). relativePath — от корня. */
|
||||
fun listUserFiles(session: AuthSession, relativePath: String): List<DavClient.DavEntry> {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val userId = davUserId(session)
|
||||
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
|
||||
val entries = DavClient.propfind(client, folderUrl, depth = 1)
|
||||
val self = folderUrl.trimEnd('/')
|
||||
// Убираем саму папку (первый ответ PROPFIND) и скрытые файлы.
|
||||
return entries
|
||||
.filter { it.href.trimEnd('/') != self && !it.name.startsWith('.') }
|
||||
.sortedWith(compareByDescending<DavClient.DavEntry> { it.isDirectory }.thenBy { it.name.lowercase() })
|
||||
}
|
||||
|
||||
/** Полный WebDAV-URL файла (для ATTACH при выборе из Файлов). */
|
||||
fun fileAttachment(session: AuthSession, relativePath: String, mimeType: String): CalendarAttachmentItem {
|
||||
val userId = davUserId(session)
|
||||
val url = davFileUrl(session.serverUrl, userId, relativePath)
|
||||
return CalendarAttachmentItem(fileName = relativePath.substringAfterLast('/'), url = url, mimeType = mimeType)
|
||||
}
|
||||
|
||||
private fun sanitizeFileName(name: String): String =
|
||||
name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
|
||||
.replace(Regex("[\\r\\n\"]"), "_")
|
||||
|
||||
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom =
|
||||
apiClient.createTalkRoomTyped(session, roomName, roomType)
|
||||
|
||||
fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
|
||||
apiClient.addTalkParticipants(session, callUrl, emails)
|
||||
}
|
||||
|
||||
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
|
||||
val ctx = openDavContext(session)
|
||||
val calendar = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
|
||||
val picked = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
|
||||
// Если выбранная коллекция не принимает VEVENT (список задач) — уводим в событийный дефолт.
|
||||
val calendar = if (picked.supportsEvents) picked else ctx.defaultCalendar
|
||||
var talkUrl = draft.talkRoomUrl
|
||||
if (draft.addTalkRoom && talkUrl.isBlank()) {
|
||||
talkUrl = apiClient.createTalkRoom(session, draft.title)
|
||||
@@ -197,10 +301,12 @@ class CalendarRepository(
|
||||
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
|
||||
val calendars = CalDavClient.listCalendars(client, base)
|
||||
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
|
||||
val default = calendars.firstOrNull { cal ->
|
||||
// Дефолт под события — только событийная коллекция (не VTODO-список задач), иначе PUT 403.
|
||||
val eventCals = calendars.filter { it.supportsEvents }.ifEmpty { calendars }
|
||||
val default = eventCals.firstOrNull { cal ->
|
||||
val name = cal.displayName.lowercase()
|
||||
name.contains("личн") || name.contains("personal") || name == "calendar"
|
||||
} ?: calendars.first()
|
||||
} ?: eventCals.first()
|
||||
return CalDavContext(client, calendars, default)
|
||||
}
|
||||
|
||||
@@ -264,6 +370,7 @@ class CalendarRepository(
|
||||
organizerEmail = organizerEmail,
|
||||
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
|
||||
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
|
||||
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
|
||||
source = CalendarEventSource.F7CLOUD,
|
||||
)
|
||||
|
||||
@@ -323,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
|
||||
}
|
||||
|
||||
@@ -361,23 +475,27 @@ data class CalendarEventItem(
|
||||
val organizerEmail: String = "",
|
||||
val talkRoomUrl: String = "",
|
||||
val alarms: List<CalendarReminderItem> = emptyList(),
|
||||
val attachments: List<CalendarAttachmentItem> = emptyList(),
|
||||
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
|
||||
)
|
||||
|
||||
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
|
||||
val zone = ZoneId.systemDefault()
|
||||
// Многодневные события: дата окончания отдельная (не раньше даты начала).
|
||||
val endDay = (endDate ?: date).coerceAtLeast(date)
|
||||
val start = if (allDay) {
|
||||
date.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
} else {
|
||||
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val end = if (allDay) {
|
||||
date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val endRaw = if (allDay) {
|
||||
endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
} else {
|
||||
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
parseDateTime(endDay, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||
?: endDay.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val end = endRaw.coerceAtLeast(start)
|
||||
val rrule = when {
|
||||
recurrence != RecurrencePreset.NONE -> recurrence.rrule
|
||||
customRrule.isNotBlank() -> customRrule
|
||||
@@ -400,8 +518,9 @@ private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String
|
||||
attendees = attendees.map {
|
||||
CalendarAttendeeData(it.email, it.displayName, it.partStat)
|
||||
},
|
||||
alarms = if (reminderMinutes > 0) listOf(CalendarAlarmData(reminderMinutes)) else emptyList(),
|
||||
alarms = reminders.filter { it >= 0 }.distinct().map { CalendarAlarmData(it) },
|
||||
conferenceUri = conf,
|
||||
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -421,6 +540,7 @@ private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventDa
|
||||
organizerEmail = organizerEmail,
|
||||
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
|
||||
conferenceUri = talkRoomUrl,
|
||||
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
|
||||
)
|
||||
|
||||
private fun CalendarEventData.toItem(
|
||||
@@ -448,6 +568,7 @@ private fun CalendarEventData.toItem(
|
||||
attendees = attendees.map { it.toItem() },
|
||||
talkRoomUrl = conferenceUri,
|
||||
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
|
||||
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
|
||||
)
|
||||
|
||||
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
|
||||
|
||||
@@ -74,6 +74,9 @@ fun CalendarScreen(
|
||||
vm.updateEventDraft { it } // no-op; snack via error path if needed
|
||||
}
|
||||
}
|
||||
val attachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
if (uri != null) vm.uploadDeviceAttachment(uri)
|
||||
}
|
||||
|
||||
fun hasCalendarPermission(): Boolean {
|
||||
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
@@ -220,6 +223,48 @@ fun CalendarScreen(
|
||||
onRemoveAttendee = vm::removeAttendee,
|
||||
onSearchLocations = vm::searchLocations,
|
||||
onApplyLocation = vm::applyLocationSuggestion,
|
||||
onOpenTalkPicker = vm::openTalkPicker,
|
||||
onPickDeviceAttachment = { attachmentLauncher.launch("*/*") },
|
||||
onPickFilesAttachment = vm::openFilesPicker,
|
||||
onRemoveAttachment = vm::removeAttachment,
|
||||
onOpenFreeBusy = vm::openFreeBusy,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.freeBusyOpen) {
|
||||
CalendarFreeBusyDialog(
|
||||
state = state,
|
||||
onDismiss = vm::closeFreeBusy,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.talkPickerOpen) {
|
||||
CalendarTalkPickerDialog(
|
||||
state = state,
|
||||
onDismiss = vm::closeTalkPicker,
|
||||
onSelect = vm::selectTalkRoom,
|
||||
onCreate = vm::createTalkConversation,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.filesPickerOpen) {
|
||||
CalendarFilesPickerDialog(
|
||||
state = state,
|
||||
onDismiss = vm::closeFilesPicker,
|
||||
onNavigate = vm::navigateFilesPicker,
|
||||
onPick = vm::pickFileAttachment,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.editCalendarOpen) {
|
||||
CalendarEditBookDialog(
|
||||
state = state,
|
||||
onDismiss = vm::closeEditCalendar,
|
||||
onSave = vm::saveCalendarProps,
|
||||
onDelete = vm::deleteCalendarConfirmed,
|
||||
onSearchSharees = vm::searchSharees,
|
||||
onAddSharee = vm::addSharee,
|
||||
onRemoveSharee = vm::removeSharee,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -287,6 +332,11 @@ fun CalendarScreen(
|
||||
onSyncNow = {
|
||||
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
|
||||
},
|
||||
onEditCalendar = { cal ->
|
||||
onSidebarOpenChange(false)
|
||||
scope.launch { drawerState.close() }
|
||||
vm.openEditCalendar(cal)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -45,6 +45,23 @@ data class CalendarUiState(
|
||||
val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
|
||||
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
|
||||
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
|
||||
val talkPickerOpen: Boolean = false,
|
||||
val talkPickerLoading: Boolean = false,
|
||||
val talkRooms: List<CalendarTalkRoom> = emptyList(),
|
||||
val attachmentUploading: Boolean = false,
|
||||
val editCalendarOpen: Boolean = false,
|
||||
val editCalendarTarget: CalendarBookItem? = null,
|
||||
val editCalendarShares: List<ru.forbion.f7cloud.core.network.CalDavClient.DavSharee> = emptyList(),
|
||||
val shareeSuggestions: List<CalendarShareeSuggestion> = emptyList(),
|
||||
val editCalendarBusy: Boolean = false,
|
||||
val freeBusyOpen: Boolean = false,
|
||||
val freeBusyLoading: Boolean = false,
|
||||
val freeBusyError: String? = null,
|
||||
val freeBusyResult: Map<String, List<ru.forbion.f7cloud.core.network.CalDavClient.BusyPeriod>> = emptyMap(),
|
||||
val filesPickerOpen: Boolean = false,
|
||||
val filesPickerLoading: Boolean = false,
|
||||
val filesPickerPath: String = "",
|
||||
val filesPickerEntries: List<ru.forbion.f7cloud.core.network.DavClient.DavEntry> = emptyList(),
|
||||
val userSettings: CalendarUserSettings = CalendarUserSettings(),
|
||||
val saving: Boolean = false,
|
||||
val deleting: Boolean = false,
|
||||
@@ -362,7 +379,7 @@ class CalendarViewModel(
|
||||
eventDraft = CalendarEventDraft(
|
||||
date = targetDay,
|
||||
calendarHref = createHref,
|
||||
reminderMinutes = _state.value.userSettings.defaultReminderMinutes,
|
||||
reminders = listOf(_state.value.userSettings.defaultReminderMinutes),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -410,6 +427,212 @@ class CalendarViewModel(
|
||||
_state.value = _state.value.copy(locationSuggestions = emptyList())
|
||||
}
|
||||
|
||||
fun openTalkPicker() {
|
||||
val s = session ?: return
|
||||
_state.value = _state.value.copy(talkPickerOpen = true, talkPickerLoading = true, talkRooms = emptyList())
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val rooms = runCatching { repository.listTalkRooms(s) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(talkRooms = rooms, talkPickerLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeTalkPicker() { _state.value = _state.value.copy(talkPickerOpen = false) }
|
||||
|
||||
fun selectTalkRoom(room: CalendarTalkRoom) {
|
||||
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
|
||||
_state.value = _state.value.copy(talkPickerOpen = false)
|
||||
}
|
||||
|
||||
/** roomType: 2 — публичная беседа, 3 — приватная (в терминах spreed). */
|
||||
fun createTalkConversation(roomType: Int) {
|
||||
val s = session ?: return
|
||||
val name = _state.value.eventDraft.title.ifBlank { "Встреча" }
|
||||
_state.value = _state.value.copy(talkPickerLoading = true)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.createTalkRoomTyped(s, name, roomType) }
|
||||
.onSuccess { room ->
|
||||
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
|
||||
_state.value = _state.value.copy(talkPickerOpen = false, talkPickerLoading = false)
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(talkPickerLoading = false, snackMessage = "Не удалось создать беседу") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Управление календарём ---
|
||||
fun openEditCalendar(cal: CalendarBookItem) {
|
||||
val s = session ?: return
|
||||
_state.value = _state.value.copy(
|
||||
editCalendarOpen = true,
|
||||
editCalendarTarget = cal,
|
||||
editCalendarShares = emptyList(),
|
||||
shareeSuggestions = emptyList(),
|
||||
)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val shares = runCatching { repository.listCalendarShares(s, cal.href) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(editCalendarShares = shares)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeEditCalendar() {
|
||||
_state.value = _state.value.copy(editCalendarOpen = false, editCalendarTarget = null, shareeSuggestions = emptyList())
|
||||
}
|
||||
|
||||
fun saveCalendarProps(displayName: String, color: String?) {
|
||||
val s = session ?: return
|
||||
val target = _state.value.editCalendarTarget ?: return
|
||||
_state.value = _state.value.copy(editCalendarBusy = true)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
repository.updateCalendarProps(
|
||||
s,
|
||||
target.href,
|
||||
displayName.trim().takeIf { it.isNotBlank() && it != target.displayName },
|
||||
color?.takeIf { it != target.color },
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
loadCalendars(s)
|
||||
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь обновлён")
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось обновить: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCalendarConfirmed() {
|
||||
val s = session ?: return
|
||||
val target = _state.value.editCalendarTarget ?: return
|
||||
_state.value = _state.value.copy(editCalendarBusy = true)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.deleteCalendar(s, target.href) }
|
||||
.onSuccess {
|
||||
loadCalendars(s)
|
||||
reloadEvents(s)
|
||||
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь удалён")
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось удалить: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun searchSharees(query: String) {
|
||||
val s = session ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val found = runCatching { repository.searchSharees(s, query) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(shareeSuggestions = found)
|
||||
}
|
||||
}
|
||||
|
||||
fun addSharee(sharee: CalendarShareeSuggestion, writable: Boolean) {
|
||||
val s = session ?: return
|
||||
val target = _state.value.editCalendarTarget ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.shareCalendar(s, target.href, sharee.principal, writable) }
|
||||
.onSuccess {
|
||||
val shares = runCatching { repository.listCalendarShares(s, target.href) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(editCalendarShares = shares, shareeSuggestions = emptyList())
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось пошарить: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSharee(principal: String) {
|
||||
val s = session ?: return
|
||||
val target = _state.value.editCalendarTarget ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.unshareCalendar(s, target.href, principal) }
|
||||
.onSuccess {
|
||||
_state.value = _state.value.copy(
|
||||
editCalendarShares = _state.value.editCalendarShares.filterNot { it.principal == principal },
|
||||
)
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось убрать доступ: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Занятость участников (free/busy) ---
|
||||
fun openFreeBusy() {
|
||||
val s = session ?: return
|
||||
val draft = _state.value.eventDraft
|
||||
val emails = draft.attendees.map { it.email }.filter { it.isNotBlank() }
|
||||
if (emails.isEmpty()) return
|
||||
_state.value = _state.value.copy(freeBusyOpen = true, freeBusyLoading = true, freeBusyError = null, freeBusyResult = emptyMap())
|
||||
val zone = ZoneId.systemDefault()
|
||||
// Диапазон — весь день события (локальные сутки).
|
||||
val start = draft.date.atStartOfDay(zone).toInstant()
|
||||
val end = draft.date.plusDays(1).atStartOfDay(zone).toInstant()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.freeBusy(s, emails, start, end) }
|
||||
.onSuccess { _state.value = _state.value.copy(freeBusyResult = it, freeBusyLoading = false) }
|
||||
.onFailure { _state.value = _state.value.copy(freeBusyLoading = false, freeBusyError = it.message) }
|
||||
}
|
||||
}
|
||||
|
||||
fun closeFreeBusy() { _state.value = _state.value.copy(freeBusyOpen = false) }
|
||||
|
||||
// --- Вложения ---
|
||||
fun uploadDeviceAttachment(uri: android.net.Uri) {
|
||||
val s = session ?: return
|
||||
_state.value = _state.value.copy(attachmentUploading = true)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val resolver = appContext.contentResolver
|
||||
val name = queryDisplayName(uri) ?: "attachment"
|
||||
val mime = resolver.getType(uri).orEmpty()
|
||||
val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||
if (bytes == null || bytes.isEmpty()) {
|
||||
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Не удалось прочитать файл")
|
||||
return@launch
|
||||
}
|
||||
runCatching { repository.uploadAttachment(s, name, mime, bytes) }
|
||||
.onSuccess { item ->
|
||||
updateEventDraft { it.copy(attachments = it.attachments + item) }
|
||||
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Файл загружен")
|
||||
}
|
||||
.onFailure { _state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Ошибка загрузки: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun openFilesPicker() {
|
||||
_state.value = _state.value.copy(filesPickerOpen = true)
|
||||
loadFilesPicker("")
|
||||
}
|
||||
|
||||
fun closeFilesPicker() { _state.value = _state.value.copy(filesPickerOpen = false) }
|
||||
|
||||
fun navigateFilesPicker(path: String) = loadFilesPicker(path)
|
||||
|
||||
private fun loadFilesPicker(path: String) {
|
||||
val s = session ?: return
|
||||
_state.value = _state.value.copy(filesPickerLoading = true, filesPickerPath = path)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val entries = runCatching { repository.listUserFiles(s, path) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(filesPickerEntries = entries, filesPickerLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun pickFileAttachment(entry: ru.forbion.f7cloud.core.network.DavClient.DavEntry) {
|
||||
val s = session ?: return
|
||||
val path = _state.value.filesPickerPath
|
||||
val rel = if (path.isBlank()) entry.name else "$path/${entry.name}"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.fileAttachment(s, rel, entry.mimeType.orEmpty()) }
|
||||
.onSuccess { item ->
|
||||
updateEventDraft { it.copy(attachments = it.attachments + item) }
|
||||
_state.value = _state.value.copy(filesPickerOpen = false, snackMessage = "Файл прикреплён")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttachment(url: String) {
|
||||
updateEventDraft { d -> d.copy(attachments = d.attachments.filterNot { it.url == url }) }
|
||||
}
|
||||
|
||||
private fun queryDisplayName(uri: android.net.Uri): String? =
|
||||
runCatching {
|
||||
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
|
||||
}
|
||||
}.getOrNull() ?: uri.lastPathSegment
|
||||
|
||||
fun openEventDetail(event: CalendarEventItem) {
|
||||
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
|
||||
}
|
||||
@@ -428,6 +651,11 @@ class CalendarViewModel(
|
||||
eventDraft = CalendarEventDraft(
|
||||
title = event.summary,
|
||||
date = start.toLocalDate(),
|
||||
// DTEND эксклюзивен для all-day → показываем последний включённый день.
|
||||
endDate = run {
|
||||
val endDay = if (event.allDay) end.toLocalDate().minusDays(1) else end.toLocalDate()
|
||||
endDay.coerceAtLeast(start.toLocalDate()).takeIf { it != start.toLocalDate() }
|
||||
},
|
||||
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
|
||||
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
|
||||
allDay = event.allDay,
|
||||
@@ -440,9 +668,10 @@ class CalendarViewModel(
|
||||
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
|
||||
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
|
||||
attendees = event.attendees,
|
||||
reminderMinutes = event.alarms.firstOrNull()?.minutesBefore ?: 15,
|
||||
reminders = event.alarms.map { it.minutesBefore }.ifEmpty { emptyList() },
|
||||
talkRoomUrl = event.talkRoomUrl,
|
||||
addTalkRoom = event.talkRoomUrl.isNotBlank(),
|
||||
attachments = event.attachments,
|
||||
),
|
||||
focusedEventUid = event.uid,
|
||||
)
|
||||
@@ -541,10 +770,10 @@ class CalendarViewModel(
|
||||
CalendarViewMode.LIST -> "Предстоящие события"
|
||||
CalendarViewMode.WEEK -> {
|
||||
val (start, end) = CalendarRepository.weekRange(st.selectedDay)
|
||||
"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} – ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"
|
||||
"${start.format(DateTimeFormatter.ofPattern("d MMM", RU))} – ${end.format(DateTimeFormatter.ofPattern("d MMM yyyy", RU))}"
|
||||
}
|
||||
CalendarViewMode.DAY, CalendarViewMode.MONTH ->
|
||||
st.selectedDay.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))
|
||||
st.selectedDay.format(DateTimeFormatter.ofPattern("d MMMM yyyy", RU))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +871,7 @@ class CalendarViewModel(
|
||||
|
||||
companion object {
|
||||
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
|
||||
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
|
||||
private val RU: Locale = Locale.forLanguageTag("ru")
|
||||
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", RU)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/** Разобранное правило повторения (подмножество RFC 5545 RRULE, как в веб-версии). */
|
||||
data class RecurrenceRule(
|
||||
val freq: String = "WEEKLY", // DAILY | WEEKLY | MONTHLY | YEARLY
|
||||
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,
|
||||
) {
|
||||
enum class EndType { NEVER, UNTIL, COUNT }
|
||||
|
||||
fun toRrule(): String = buildString {
|
||||
append("FREQ=$freq")
|
||||
if (interval > 1) append(";INTERVAL=$interval")
|
||||
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) {
|
||||
EndType.UNTIL -> untilDate?.let { append(";UNTIL=${it.format(UNTIL_FMT)}T235959Z") }
|
||||
EndType.COUNT -> append(";COUNT=${count.coerceAtLeast(1)}")
|
||||
EndType.NEVER -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ICS_DAYS = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU")
|
||||
val DAY_LABELS = mapOf(
|
||||
"MO" to "Пн", "TU" to "Вт", "WE" to "Ср", "TH" to "Чт",
|
||||
"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? {
|
||||
if (rrule.isBlank()) return null
|
||||
val parts = rrule.trim().removePrefix("RRULE:").split(';')
|
||||
.mapNotNull { p ->
|
||||
val idx = p.indexOf('=')
|
||||
if (idx <= 0) null else p.substring(0, idx).uppercase() to p.substring(idx + 1)
|
||||
}.toMap()
|
||||
val freq = parts["FREQ"]?.uppercase() ?: return null
|
||||
if (freq !in setOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY")) return null
|
||||
val until = parts["UNTIL"]?.let { raw ->
|
||||
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 = bydayTokens.map { it.takeLast(2) }.filter { it in ICS_DAYS }.toSet(),
|
||||
monthlyOrdinal = monthlyOrdinal,
|
||||
endType = when {
|
||||
until != null -> EndType.UNTIL
|
||||
count != null -> EndType.COUNT
|
||||
else -> EndType.NEVER
|
||||
},
|
||||
untilDate = until,
|
||||
count = count ?: 10,
|
||||
)
|
||||
}
|
||||
|
||||
/** Человекочитаемое описание правила по-русски (для пилюли «Повторение»). */
|
||||
fun describe(rrule: String): String {
|
||||
val rule = parse(rrule) ?: return "Не повторять"
|
||||
val freqLabel = when (rule.freq) {
|
||||
"DAILY" -> if (rule.interval == 1) "Каждый день" else "Каждые ${rule.interval} дн."
|
||||
"WEEKLY" -> if (rule.interval == 1) "Каждую неделю" else "Каждые ${rule.interval} нед."
|
||||
"MONTHLY" -> if (rule.interval == 1) "Каждый месяц" else "Каждые ${rule.interval} мес."
|
||||
else -> if (rule.interval == 1) "Каждый год" else "Каждые ${rule.interval} г."
|
||||
}
|
||||
val days = if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
|
||||
" по " + ICS_DAYS.filter { it in rule.byDays }.joinToString(", ") { DAY_LABELS[it] ?: it }
|
||||
} else ""
|
||||
val end = when (rule.endType) {
|
||||
EndType.UNTIL -> rule.untilDate?.let { " до ${it.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}" }.orEmpty()
|
||||
EndType.COUNT -> ", ${rule.count} раз"
|
||||
EndType.NEVER -> ""
|
||||
}
|
||||
return freqLabel + days + end
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force && !needsPhotoBackfill && !needsDetailsBackfill && System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS</ID>
|
||||
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, )</ID>
|
||||
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, onEdit: () -> Unit, onDelete: () -> 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<DeckLabel>, stacks: List<DeckStack>, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -> Unit, onToggleDone: () -> Unit, onSave: (title: String, description: String, duedate: String?) -> Unit, onToggleLabel: (DeckLabel) -> Unit, onToggleAssignSelf: () -> Unit, onMove: (targetStackId: Int) -> Unit, onArchive: () -> Unit, onDelete: () -> Unit, )</ID>
|
||||
<ID>LongMethod:DeckCardSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckCardSheet( card: DeckCard, boardLabels: List<DeckLabel>, stacks: List<DeckStack>, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -> Unit, onToggleDone: () -> Unit, onSave: (title: String, description: String, duedate: String?) -> Unit, onToggleLabel: (DeckLabel) -> Unit, onToggleAssignSelf: () -> Unit, onMove: (targetStackId: Int) -> Unit, onArchive: () -> Unit, onDelete: () -> Unit, )</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>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
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.text.font.FontWeight
|
||||
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 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
|
||||
fun DeckCardSheet(
|
||||
card: DeckCard,
|
||||
boardLabels: List<DeckLabel>,
|
||||
stacks: List<DeckStack>,
|
||||
canEdit: Boolean,
|
||||
busy: Boolean,
|
||||
assignedToMe: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onToggleDone: () -> Unit,
|
||||
onSave: (title: String, description: String, duedate: String?) -> Unit,
|
||||
onToggleLabel: (DeckLabel) -> Unit,
|
||||
onToggleAssignSelf: () -> Unit,
|
||||
onMove: (targetStackId: Int) -> Unit,
|
||||
onArchive: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var title by remember(card.id) { mutableStateOf(card.title) }
|
||||
var description by remember(card.id) { mutableStateOf(card.description) }
|
||||
// duedate храним как ISO-строку (как отдаёт/принимает Deck), null = без срока
|
||||
var duedate by remember(card.id) { mutableStateOf(card.duedate) }
|
||||
var datePickerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.96f).heightIn(max = 680.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Done + заголовок
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Checkbox(
|
||||
checked = card.done,
|
||||
onCheckedChange = { if (canEdit) onToggleDone() },
|
||||
enabled = canEdit && !busy,
|
||||
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
|
||||
)
|
||||
Text(
|
||||
if (card.done) "Выполнена" else "Не выполнена",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
F7OutlinedField(value = title, onValueChange = { title = it }, label = "Название")
|
||||
F7OutlinedField(value = description, onValueChange = { description = it }, label = "Описание", minLines = 3)
|
||||
|
||||
// Срок
|
||||
Text("Срок", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 36.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(F7Colors.SurfaceMuted)
|
||||
.clickable(enabled = canEdit) { datePickerOpen = true }
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
duedate?.let { formatDeckDue(it) } ?: "Без срока",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (duedate != null) F7Colors.TextPrimary else F7Colors.TextSecondary,
|
||||
)
|
||||
if (duedate != null) {
|
||||
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()) {
|
||||
Text("Метки", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
boardLabels.forEach { label ->
|
||||
val active = card.labels.any { it.id == label.id }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
(parseDeckColor(label.color) ?: F7Colors.Primary)
|
||||
.copy(alpha = if (active) 0.35f else 0.12f),
|
||||
)
|
||||
.border(
|
||||
if (active) 2.dp else 1.dp,
|
||||
parseDeckColor(label.color) ?: F7Colors.Primary,
|
||||
RoundedCornerShape(4.dp),
|
||||
)
|
||||
.clickable(enabled = canEdit && !busy) { onToggleLabel(label) }
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(label.title, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Исполнители
|
||||
Text("Исполнители", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
if (card.assignees.isNotEmpty()) {
|
||||
Text(
|
||||
card.assignees.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
if (canEdit) {
|
||||
F7SecondaryButton(
|
||||
text = if (assignedToMe) "Снять назначение с меня" else "Назначить мне",
|
||||
onClick = onToggleAssignSelf,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// Переместить в колонку
|
||||
if (canEdit && stacks.size > 1) {
|
||||
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
stacks.forEach { stack ->
|
||||
val current = stack.id == card.stackId
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(if (current) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
|
||||
.clickable(enabled = !current && !busy) { onMove(stack.id) }
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(stack.title, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canEdit) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||
F7PrimaryButton(
|
||||
text = if (busy) "…" else "Сохранить",
|
||||
onClick = { onSave(title, description, duedate) },
|
||||
enabled = !busy && title.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("В архив", onClick = onArchive, enabled = !busy, modifier = Modifier.weight(1f))
|
||||
F7SecondaryButton("Удалить", onClick = onDelete, enabled = !busy, modifier = Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (datePickerOpen) {
|
||||
val initMillis = duedate?.let { parseDeckDueMillis(it) }
|
||||
?: System.currentTimeMillis()
|
||||
val pickerState = rememberDatePickerState(initialSelectedDateMillis = initMillis)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { datePickerOpen = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pickerState.selectedDateMillis?.let { millis ->
|
||||
// Deck принимает ISO-8601 (в полдень UTC достаточно для «дня»)
|
||||
val instant = Instant.ofEpochMilli(millis)
|
||||
duedate = DateTimeFormatter.ISO_INSTANT.format(instant)
|
||||
}
|
||||
datePickerOpen = false
|
||||
}) { Text("ОК", color = F7Colors.Primary) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { datePickerOpen = false }) { Text("Отмена", color = F7Colors.TextSecondary) }
|
||||
},
|
||||
) { DatePicker(state = pickerState) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeckTextPromptDialog(
|
||||
title: String,
|
||||
label: String,
|
||||
confirmText: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
initial: String = "",
|
||||
) {
|
||||
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)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
F7OutlinedField(value = text, onValueChange = { text = it }, label = label)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||
F7PrimaryButton(
|
||||
text = confirmText,
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
}.getOrElse {
|
||||
runCatching {
|
||||
java.time.OffsetDateTime.parse(iso).toInstant().toEpochMilli()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Отображение срока: «12 мар». */
|
||||
internal fun formatDeckDue(iso: String): String {
|
||||
val millis = parseDeckDueMillis(iso) ?: return iso.take(10)
|
||||
val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
return date.format(DateTimeFormatter.ofPattern("d MMM"))
|
||||
}
|
||||
@@ -1,91 +1,266 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
/**
|
||||
* Deck через внутренний фронт-API (`/index.php/apps/deck/api/v1.0`) — тот же, что у веб-версии.
|
||||
* Пишущие запросы авторизуются app-password + OCS-APIRequest (CSRF при app-password не требуется).
|
||||
*/
|
||||
class DeckRepository {
|
||||
private fun apiBase(session: AuthSession): String {
|
||||
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
||||
}
|
||||
private fun apiBase(session: AuthSession): String =
|
||||
"${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
||||
|
||||
private fun client(session: AuthSession): OkHttpClient =
|
||||
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
|
||||
// --- Чтение ---
|
||||
|
||||
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val json = getJson(client, "${apiBase(session)}/boards")
|
||||
val array = when (json) {
|
||||
is JSONArray -> json
|
||||
is JSONObject -> JSONArray().put(json)
|
||||
else -> JSONArray()
|
||||
}
|
||||
val json = getJson(client(session), "${apiBase(session)}/boards")
|
||||
val array = json as? JSONArray ?: (json as? JSONObject)?.let { JSONArray().put(it) } ?: JSONArray()
|
||||
val out = mutableListOf<DeckBoard>()
|
||||
for (i in 0 until array.length()) {
|
||||
val board = array.optJSONObject(i) ?: continue
|
||||
val id = board.optInt("id", 0)
|
||||
val title = board.optString("title")
|
||||
if (id > 0 && title.isNotBlank()) {
|
||||
out += DeckBoard(id = id, title = title, color = board.optString("color"))
|
||||
if (id > 0 && !board.optBoolean("archived", false)) {
|
||||
out += DeckBoard(
|
||||
id = id,
|
||||
title = board.optString("title"),
|
||||
color = board.optString("color"),
|
||||
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
|
||||
val json = getJson(client(session), "${apiBase(session)}/cards/$cardId") as JSONObject
|
||||
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
|
||||
if (boardId <= 0) error("Карточка не найдена")
|
||||
return DeckCardDetail(
|
||||
cardId = cardId,
|
||||
boardId = boardId,
|
||||
title = json.optString("title"),
|
||||
)
|
||||
return DeckCardDetail(cardId = cardId, boardId = boardId, title = json.optString("title"))
|
||||
}
|
||||
|
||||
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks")
|
||||
val stacksArray = when (stacksJson) {
|
||||
is JSONArray -> stacksJson
|
||||
else -> JSONArray()
|
||||
}
|
||||
val c = client(session)
|
||||
// Доска-объект (метки/права) — НЕ критична: если запрос упадёт, доску всё равно
|
||||
// открываем со стеками (раньше был баг — сбой board#read рушил всю загрузку).
|
||||
var boardTitle = ""
|
||||
var labels = emptyList<DeckLabel>()
|
||||
var canEdit = true
|
||||
runCatching { getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject }
|
||||
.onSuccess { board ->
|
||||
boardTitle = board.optString("title")
|
||||
labels = parseLabels(board.optJSONArray("labels"))
|
||||
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
|
||||
}
|
||||
val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
|
||||
val stacks = mutableListOf<DeckStack>()
|
||||
for (i in 0 until stacksArray.length()) {
|
||||
val stack = stacksArray.optJSONObject(i) ?: continue
|
||||
for (i in 0 until stacksJson.length()) {
|
||||
val stack = stacksJson.optJSONObject(i) ?: continue
|
||||
val stackId = stack.optInt("id", 0)
|
||||
val title = stack.optString("title")
|
||||
val cards = mutableListOf<DeckCard>()
|
||||
if (stackId <= 0) continue
|
||||
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
|
||||
for (c in 0 until cardsArray.length()) {
|
||||
val card = cardsArray.optJSONObject(c) ?: continue
|
||||
val cardTitle = card.optString("title")
|
||||
if (cardTitle.isNotBlank()) {
|
||||
cards += DeckCard(
|
||||
id = card.optInt("id", 0),
|
||||
title = cardTitle,
|
||||
done = card.has("done") && !card.isNull("done"),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (stackId > 0) {
|
||||
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
|
||||
val cards = mutableListOf<DeckCard>()
|
||||
for (cIdx in 0 until cardsArray.length()) {
|
||||
cardsArray.optJSONObject(cIdx)?.let { cards += parseCard(it, stackId) }
|
||||
}
|
||||
stacks += DeckStack(
|
||||
id = stackId,
|
||||
boardId = boardId,
|
||||
title = stack.optString("title").ifBlank { "Колонка" },
|
||||
order = stack.optInt("order", i),
|
||||
cards = cards.sortedBy { it.order },
|
||||
)
|
||||
}
|
||||
return DeckBoardDetail(boardId = boardId, stacks = stacks)
|
||||
return DeckBoardDetail(
|
||||
boardId = boardId,
|
||||
title = boardTitle,
|
||||
canEdit = canEdit,
|
||||
labels = labels,
|
||||
stacks = stacks.sortedBy { it.order },
|
||||
)
|
||||
}
|
||||
|
||||
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
|
||||
private fun parseCard(card: JSONObject, stackId: Int): DeckCard = DeckCard(
|
||||
id = card.optInt("id", 0),
|
||||
title = card.optString("title"),
|
||||
done = !card.isNull("done") && card.optString("done").isNotBlank(),
|
||||
description = card.optString("description"),
|
||||
duedate = card.optString("duedate").takeIf { it.isNotBlank() && it != "null" },
|
||||
order = card.optInt("order", 0),
|
||||
stackId = stackId,
|
||||
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> {
|
||||
if (array == null) return emptyList()
|
||||
val out = mutableListOf<DeckLabel>()
|
||||
for (i in 0 until array.length()) {
|
||||
val l = array.optJSONObject(i) ?: continue
|
||||
val id = l.optInt("id", 0)
|
||||
if (id > 0) out += DeckLabel(id = id, title = l.optString("title"), color = l.optString("color"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseAssignees(array: JSONArray?): List<String> {
|
||||
if (array == null) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val a = array.optJSONObject(i) ?: continue
|
||||
val p = a.optJSONObject("participant")
|
||||
val name = p?.optString("displayname")?.takeIf { it.isNotBlank() }
|
||||
?: p?.optString("uid")?.takeIf { it.isNotBlank() }
|
||||
?: a.optString("displayname")
|
||||
if (name.isNotBlank()) out += name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** uid'ы назначенных пользователей (type 0 — user) — для «назначен ли текущий пользователь». */
|
||||
private fun parseAssigneeUids(array: JSONArray?): List<String> {
|
||||
if (array == null) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
for (i in 0 until array.length()) {
|
||||
val a = array.optJSONObject(i)
|
||||
if (a != null && a.optInt("type", 0) == 0) {
|
||||
val uid = a.optJSONObject("participant")?.optString("uid")?.takeIf { it.isNotBlank() }
|
||||
?: a.optString("participant").takeIf { it.isNotBlank() }
|
||||
if (!uid.isNullOrBlank()) out += uid
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Запись ---
|
||||
|
||||
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
|
||||
val body = JSONObject().put("title", title).put("type", "plain").put("order", 999).put("stackId", stackId)
|
||||
val json = sendJson(session, "POST", "${apiBase(session)}/cards", body) as? JSONObject
|
||||
return json?.let { parseCard(it, stackId) }
|
||||
?: DeckCard(id = 0, title = title, done = false, stackId = stackId)
|
||||
}
|
||||
|
||||
fun updateCard(
|
||||
session: AuthSession,
|
||||
card: DeckCard,
|
||||
title: String,
|
||||
description: String,
|
||||
duedate: String?,
|
||||
) {
|
||||
val body = JSONObject()
|
||||
.put("title", title)
|
||||
.put("type", "plain")
|
||||
.put("owner", session.username)
|
||||
.put("description", description)
|
||||
.put("order", card.order)
|
||||
.put("stackId", card.stackId)
|
||||
.put("duedate", duedate ?: JSONObject.NULL)
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/${card.id}", body)
|
||||
}
|
||||
|
||||
fun setCardDone(session: AuthSession, cardId: Int, done: Boolean) {
|
||||
val path = if (done) "done" else "undone"
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/$path", JSONObject())
|
||||
}
|
||||
|
||||
fun moveCard(session: AuthSession, cardId: Int, targetStackId: Int, order: Int = 0) {
|
||||
val body = JSONObject().put("stackId", targetStackId).put("order", order)
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/reorder", body)
|
||||
}
|
||||
|
||||
fun archiveCard(session: AuthSession, cardId: Int) {
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/archive", JSONObject())
|
||||
}
|
||||
|
||||
fun deleteCard(session: AuthSession, cardId: Int) {
|
||||
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId", null)
|
||||
}
|
||||
|
||||
fun assignLabel(session: AuthSession, cardId: Int, labelId: Int) {
|
||||
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/label/$labelId", JSONObject())
|
||||
}
|
||||
|
||||
fun removeLabel(session: AuthSession, cardId: Int, labelId: Int) {
|
||||
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
|
||||
}
|
||||
|
||||
/** Назначить пользователя (type 0) на карточку. */
|
||||
fun assignUser(session: AuthSession, cardId: Int, uid: String) {
|
||||
val body = JSONObject().put("userId", uid).put("type", 0)
|
||||
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/assign", body)
|
||||
}
|
||||
|
||||
/** Снять назначение пользователя (type 0) с карточки. */
|
||||
fun unassignUser(session: AuthSession, cardId: Int, uid: String) {
|
||||
val body = JSONObject().put("userId", uid).put("type", 0)
|
||||
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/unassign", body)
|
||||
}
|
||||
|
||||
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
|
||||
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
|
||||
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
|
||||
val id = json?.optInt("id", 0) ?: 0
|
||||
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 {
|
||||
// GET — как в прежней рабочей версии (без доп. заголовков); запись отдельно в sendJson.
|
||||
val request = Request.Builder().url(url).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Deck API HTTP ${response.code}")
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) error("Deck API HTTP ${response.code}")
|
||||
val body = response.body!!.string().trim()
|
||||
if (body.startsWith("[")) return JSONArray(body)
|
||||
if (body.startsWith("{")) return JSONObject(body)
|
||||
return JSONArray()
|
||||
return when {
|
||||
body.startsWith("[") -> JSONArray(body)
|
||||
body.startsWith("{") -> JSONObject(body)
|
||||
else -> JSONArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val jsonMedia = "application/json".toMediaType()
|
||||
|
||||
private fun sendJson(session: AuthSession, method: String, url: String, body: JSONObject?): Any? {
|
||||
val payload = (body?.toString() ?: "{}").toRequestBody(jsonMedia)
|
||||
val builder = Request.Builder()
|
||||
.url(url)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
when (method) {
|
||||
"POST" -> builder.post(payload)
|
||||
"PUT" -> builder.put(payload)
|
||||
"DELETE" -> if (body != null) builder.delete(payload) else builder.delete()
|
||||
}
|
||||
client(session).newCall(builder.build()).execute().use { response ->
|
||||
if (!response.isSuccessful) error("Deck API HTTP ${response.code}")
|
||||
val text = response.body?.string()?.trim().orEmpty()
|
||||
return when {
|
||||
text.startsWith("{") -> JSONObject(text)
|
||||
text.startsWith("[") -> JSONArray(text)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,11 +269,20 @@ data class DeckBoard(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val color: String,
|
||||
val canEdit: Boolean = true,
|
||||
)
|
||||
|
||||
data class DeckLabel(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val color: String,
|
||||
)
|
||||
|
||||
data class DeckStack(
|
||||
val id: Int,
|
||||
val boardId: Int,
|
||||
val title: String,
|
||||
val order: Int,
|
||||
val cards: List<DeckCard>,
|
||||
)
|
||||
|
||||
@@ -106,10 +290,21 @@ data class DeckCard(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val done: Boolean,
|
||||
val description: String = "",
|
||||
val duedate: String? = null,
|
||||
val order: Int = 0,
|
||||
val stackId: Int = 0,
|
||||
val labels: List<DeckLabel> = emptyList(),
|
||||
val assignees: List<String> = emptyList(),
|
||||
val assigneeUids: List<String> = emptyList(),
|
||||
val commentsCount: Int = 0,
|
||||
)
|
||||
|
||||
data class DeckBoardDetail(
|
||||
val boardId: Int,
|
||||
val title: String,
|
||||
val canEdit: Boolean,
|
||||
val labels: List<DeckLabel>,
|
||||
val stacks: List<DeckStack>,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,26 +1,58 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
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.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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ListCard
|
||||
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.F7SecondaryButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DeckScreen(
|
||||
session: AuthSession,
|
||||
@@ -31,23 +63,20 @@ fun DeckScreen(
|
||||
) {
|
||||
val vm: DeckViewModel = viewModel()
|
||||
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(session.serverUrl, session.username) { vm.load(session) }
|
||||
LaunchedEffect(openCardId) {
|
||||
val cardId = openCardId ?: return@LaunchedEffect
|
||||
vm.openCardById(session, cardId)
|
||||
onOpenCardConsumed()
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) {
|
||||
if (state.unauthorized) onUnauthorized()
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() }
|
||||
|
||||
F7OverlayDismissHandler(
|
||||
enabled = state.boardDetail != null,
|
||||
onDismiss = vm::closeBoard,
|
||||
)
|
||||
F7OverlayDismissHandler(enabled = state.boardDetail != null, onDismiss = vm::closeBoard)
|
||||
|
||||
F7ModuleScreen(
|
||||
modifier = modifier,
|
||||
@@ -55,7 +84,12 @@ fun DeckScreen(
|
||||
error = state.error,
|
||||
onRefresh = { vm.load(session) },
|
||||
headerActions = {
|
||||
if (state.selectedBoardId != null) {
|
||||
val detail = state.boardDetail
|
||||
if (detail != null) {
|
||||
if (detail.canEdit) {
|
||||
F7SecondaryButton(text = "+ Колонка", onClick = { addStackOpen = true })
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() })
|
||||
}
|
||||
},
|
||||
@@ -66,31 +100,328 @@ fun DeckScreen(
|
||||
onRefresh = {
|
||||
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) {
|
||||
if (detail != null) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(detail.stacks, key = { it.id }) { stack ->
|
||||
F7ListCard {
|
||||
Text(stack.title, style = MaterialTheme.typography.titleSmall)
|
||||
stack.cards.forEach { card ->
|
||||
val prefix = if (card.done) "✓ " else "• "
|
||||
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
DeckStackSection(
|
||||
stack = stack,
|
||||
canEdit = detail.canEdit,
|
||||
onCardClick = { vm.openCard(it) },
|
||||
onToggleDone = { vm.toggleDone(session, it) },
|
||||
onAddCard = { addCardStackId = stack.id },
|
||||
onRename = { renameStackTarget = stack },
|
||||
onDelete = { deleteStackTarget = stack },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.boards, key = { it.id }) { board ->
|
||||
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
||||
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
DeckBoardRow(board = board, onClick = { vm.openBoard(session, board.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Карточка-деталь
|
||||
state.openedCard?.let { card ->
|
||||
DeckCardSheet(
|
||||
card = card,
|
||||
boardLabels = state.boardDetail?.labels.orEmpty(),
|
||||
stacks = state.boardDetail?.stacks.orEmpty(),
|
||||
canEdit = state.boardDetail?.canEdit ?: false,
|
||||
busy = state.busy,
|
||||
assignedToMe = card.assigneeUids.contains(session.username),
|
||||
onDismiss = vm::closeCard,
|
||||
onToggleDone = { vm.toggleDone(session, card) },
|
||||
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
|
||||
onToggleLabel = { vm.toggleLabel(session, card, it) },
|
||||
onToggleAssignSelf = { vm.toggleAssignSelf(session, card) },
|
||||
onMove = { vm.moveCard(session, card, it) },
|
||||
onArchive = { vm.archiveCard(session, card) },
|
||||
onDelete = { vm.deleteCard(session, card) },
|
||||
)
|
||||
}
|
||||
|
||||
if (addStackOpen) {
|
||||
DeckTextPromptDialog(
|
||||
title = "Новая колонка",
|
||||
label = "Название колонки",
|
||||
confirmText = "Создать",
|
||||
onConfirm = { vm.createStack(session, it); addStackOpen = false },
|
||||
onDismiss = { addStackOpen = false },
|
||||
)
|
||||
}
|
||||
addCardStackId?.let { stackId ->
|
||||
DeckTextPromptDialog(
|
||||
title = "Новая карточка",
|
||||
label = "Название карточки",
|
||||
confirmText = "Создать",
|
||||
onConfirm = { vm.createCard(session, stackId, it); addCardStackId = null },
|
||||
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 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()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFDFDFD))
|
||||
.border(1.dp, Color(0xFFF0F1F4), RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(parseDeckColor(board.color) ?: F7Colors.Primary),
|
||||
)
|
||||
Text(
|
||||
board.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
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()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(F7Colors.Grey2)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stack.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text(
|
||||
stack.cards.size.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
if (canEdit) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.PrimaryLight)
|
||||
.clickable(onClick = onAddCard),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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 ->
|
||||
DeckCardRow(card = card, onClick = { onCardClick(card) }, onToggleDone = { onToggleDone(card) })
|
||||
}
|
||||
if (stack.cards.isEmpty()) {
|
||||
Text("Нет карточек", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFDFDFD))
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (card.labels.isNotEmpty()) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
card.labels.forEach { DeckLabelChip(it) }
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.border(1.5.dp, if (card.done) F7Colors.Primary else F7Colors.Grey1, RoundedCornerShape(4.dp))
|
||||
.background(if (card.done) F7Colors.Primary else Color.Transparent)
|
||||
.clickable(onClick = onToggleDone),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (card.done) Text("✓", color = Color.White, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
card.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val due = card.duedate?.let { formatDeckDue(it) }
|
||||
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),
|
||||
modifier = Modifier.padding(start = 28.dp),
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeckLabelChip(label: DeckLabel) {
|
||||
val bg = parseDeckColor(label.color) ?: F7Colors.Primary
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(bg.copy(alpha = 0.22f))
|
||||
.border(1.dp, bg, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
label.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return runCatching { Color(("FF$h").toLong(16)) }.getOrNull()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
@@ -15,6 +16,8 @@ data class DeckUiState(
|
||||
val boards: List<DeckBoard> = emptyList(),
|
||||
val selectedBoardId: Int? = null,
|
||||
val boardDetail: DeckBoardDetail? = null,
|
||||
val openedCard: DeckCard? = null,
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
)
|
||||
@@ -27,61 +30,130 @@ class DeckViewModel(
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
runCatching { repository.loadBoards(session) }
|
||||
.onSuccess { boards ->
|
||||
_state.value = _state.value.copy(loading = false, boards = boards, error = null)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
.onSuccess { boards -> _state.update { it.copy(loading = false, boards = boards, error = null) } }
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openBoard(session: AuthSession, boardId: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
selectedBoardId = boardId,
|
||||
boardDetail = null,
|
||||
error = null,
|
||||
)
|
||||
_state.update { it.copy(loading = true, selectedBoardId = boardId, boardDetail = null, error = null) }
|
||||
runCatching { repository.loadBoardDetail(session, boardId) }
|
||||
.onSuccess { detail -> _state.update { it.copy(loading = false, boardDetail = detail) } }
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadBoard(session: AuthSession) {
|
||||
val boardId = _state.value.selectedBoardId ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.loadBoardDetail(session, boardId) }
|
||||
.onSuccess { detail ->
|
||||
_state.value = _state.value.copy(loading = false, boardDetail = detail)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
// если открыта карточка — подставим её свежую версию
|
||||
val openedId = _state.value.openedCard?.id
|
||||
val freshCard = openedId?.let { id ->
|
||||
detail.stacks.flatMap { it.cards }.firstOrNull { it.id == id }
|
||||
}
|
||||
_state.update { it.copy(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false) }
|
||||
}
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
fun closeBoard() {
|
||||
_state.value = _state.value.copy(selectedBoardId = null, boardDetail = null)
|
||||
_state.update { it.copy(selectedBoardId = null, boardDetail = null, openedCard = null) }
|
||||
}
|
||||
|
||||
fun openCard(card: DeckCard) {
|
||||
_state.update { it.copy(openedCard = card) }
|
||||
}
|
||||
|
||||
fun closeCard() {
|
||||
_state.update { it.copy(openedCard = null) }
|
||||
}
|
||||
|
||||
fun openCardById(session: AuthSession, cardId: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
runCatching { repository.loadCard(session, cardId) }
|
||||
.onSuccess { card ->
|
||||
openBoard(session, card.boardId)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message ?: "Не удалось открыть карточку",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
.onSuccess { card -> openBoard(session, card.boardId) }
|
||||
.onFailure { t -> fail(t, "Не удалось открыть карточку") }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Действия (оптимистично не мутируем, перечитываем доску) ---
|
||||
|
||||
fun createCard(session: AuthSession, stackId: Int, title: String) =
|
||||
mutate(session) { repository.createCard(session, stackId, title.trim()) }
|
||||
|
||||
fun toggleDone(session: AuthSession, card: DeckCard) =
|
||||
mutate(session) { repository.setCardDone(session, card.id, !card.done) }
|
||||
|
||||
fun updateCard(session: AuthSession, card: DeckCard, title: String, description: String, duedate: String?) =
|
||||
mutate(session) { repository.updateCard(session, card, title.trim(), description.trim(), duedate) }
|
||||
|
||||
fun moveCard(session: AuthSession, card: DeckCard, targetStackId: Int) =
|
||||
mutate(session) { repository.moveCard(session, card.id, targetStackId) }
|
||||
|
||||
fun archiveCard(session: AuthSession, card: DeckCard) =
|
||||
mutate(session, closeCardAfter = true) { repository.archiveCard(session, card.id) }
|
||||
|
||||
fun deleteCard(session: AuthSession, card: DeckCard) =
|
||||
mutate(session, closeCardAfter = true) { repository.deleteCard(session, card.id) }
|
||||
|
||||
fun toggleLabel(session: AuthSession, card: DeckCard, label: DeckLabel) = mutate(session) {
|
||||
if (card.labels.any { it.id == label.id }) {
|
||||
repository.removeLabel(session, card.id, label.id)
|
||||
} else {
|
||||
repository.assignLabel(session, card.id, label.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAssignSelf(session: AuthSession, card: DeckCard) = mutate(session) {
|
||||
if (card.assigneeUids.contains(session.username)) {
|
||||
repository.unassignUser(session, card.id, session.username)
|
||||
} else {
|
||||
repository.assignUser(session, card.id, session.username)
|
||||
}
|
||||
}
|
||||
|
||||
fun createStack(session: AuthSession, title: String) {
|
||||
val boardId = _state.value.selectedBoardId ?: return
|
||||
mutate(session) { repository.createStack(session, boardId, title.trim()) }
|
||||
}
|
||||
|
||||
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,
|
||||
action: () -> Unit,
|
||||
) {
|
||||
_state.update { it.copy(busy = true, error = null) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { action() }
|
||||
.onSuccess {
|
||||
if (closeCardAfter) _state.update { it.copy(openedCard = null) }
|
||||
reloadBoard(session)
|
||||
}
|
||||
.onFailure { t -> fail(t) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(t: Throwable, fallback: String? = null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
busy = false,
|
||||
error = t.message ?: fallback,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||