62 Commits

Author SHA1 Message Date
b-dev-mobile f70b1861bd chore: версия 0.5.137 (145)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:12:19 +00:00
b-dev-mobile 6cdef16952 fix(files): .whiteboard белый экран — не прятать chrome (сносился #body-user)
Скрипт HIDE_F7CLOUD_CHROME_JS делал hide(#body-user) — это ВЕСЬ контейнер
приложения Nextcloud; на Collabora-странице документ в отдельном
#documents-content, а на Files-странице (whiteboard) внутри #body-user
лежит и сам viewer → удалялось всё → белый экран. Теперь скрипт
инжектится ТОЛЬКО для Collabora (collaboraBaseUrl не пуст); для web-файлов
chrome не трогаем — доска рисуется в штатном viewer.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:47:53 +00:00
b-dev-mobile 47534ceb59 refactor: session/auth в MainViewModel (Этап 2 п.10, ч.2)
Верхнеуровневый session-жизненный цикл вынесен из AppScaffold в MainViewModel
(AndroidViewModel): держит session (StateFlow), login()=save+set,
logout()=ревокация app password + OfficeWarmup.clear + WebViewPool.dispose +
AuthStore.clear. AppScaffold читает vm.session.collectAsState(), forceLogout=
{vm.logout()}. Бонус: сессия переживает пересоздание Activity (поворот) —
раньше жила в remember и перечитывалась из AuthStore. UI-тумблеры (menuOpen,
sidebar/settings-флаги, pending* deep-link) осознанно оставлены в composable —
это чистое UI-состояние, не бизнес-логика.
2026-07-09 15:02:21 +00:00
b-dev-mobile 8c90e83cef refactor: LoginScreen вынесен из AppScaffold в свой файл (Этап 2 п.10, ч.1)
LoginScreen (~234 строки) был вложен в файл-«бог» AppScaffold. Вынесен в
LoginScreen.kt (internal, тот же пакет) — чистый перенос, поведение не
менялось. AppScaffold: 925→691 строк, убрано 22 импорта, использовавшихся
только в LoginScreen. Первый безопасный слой разбора AppScaffold.
2026-07-09 14:42:55 +00:00
b-dev-mobile a674721353 ux: единый экран ошибки F7ErrorState + retry (Этап 2 п.12)
Новый F7ErrorState: центрированная иконка + сообщение + «Повторить»
вместо россыпи красного текста. F7ModuleScreen при ошибке без данных
показывает его (+ новый onErrorRetry — retry без хедер-кнопки ↻).
Подключено: Контакты/Конференции/Карточки/Поддержка (через F7ModuleScreen),
Файлы (onErrorRetry=refreshCurrent), Почта (пустой список + ошибка →
F7ErrorState, убран верхний баннер-текст). Retry везде дергает load/refresh.
2026-07-09 14:08:36 +00:00
b-dev-mobile 8f7eaff611 ux: SplashScreen на холодном старте (Этап 2 п.12)
androidx core-splashscreen 1.0.1: тема Theme.F7.Splash (фон #FBFBFB как
F7Colors.Background + иконка ic_launcher), postSplashScreenTheme — прежняя
тема приложения. installSplashScreen() в MainActivity.onCreate до super.
Вместо голого белого экрана при запуске.
2026-07-09 13:13:01 +00:00
b-dev-mobile 83e071673c talk-native: убран runBlocking со старта и пути звонка (аудит C4/п.7)
TalkVendorUserSync.ensureUser → suspend(IO). Прогрев на старте приложения —
в фоновом scope (раньше блокировал main в Application.onCreate, вкл. чтение
AuthStore/Keystore). Путь звонка: подготовка vendor-юзера в корутине (IO),
запуск CallActivity на main — убран ANR-риск на входящем звонке.
Поведение то же: при неготовности — тот же тост, фолбэк WebView сохранён.
2026-07-09 12:58:55 +00:00
b-dev-mobile d1d61d0cf8 ux: pull-to-refresh на всех списках (Этап 2 п.12)
Раньше свайп-обновление было только в Файлах. Добавлено (Material3
PullToRefreshBox): Почта (входящие/исходящие, force-обновление), Конференции
(список бесед — заменяет убранную кнопку ↻), Контакты (force + CTag — дёшево),
Задачи (список), Карточки (доски и открытая доска). Контакты: refresh()
получил параметр force (pull-to-refresh форсит sync мимо TTL; с CTag-проверкой
это 1 лёгкий PROPFIND).
2026-07-09 12:48:08 +00:00
b-dev-mobile 286ab9c8bf gitignore: dist/ (локальные архивы исходников для релизов) 2026-07-09 09:41:13 +00:00
b-dev-mobile b03cb09a6c changelog: календарь — кэш событий по CTag 2026-07-09 09:40:28 +00:00
b-dev-mobile fb57156ee9 calendar: кэш событий по CTag (Этап 2, DAV ч.2)
listCalendars теперь забирает и cs:getctag (тот же PROPFIND — ноль лишних
запросов; DavCalendar.ctag). Новый CalendarEventsCache — дисковый кэш
СЫРЫХ REPORT-ответов per (аккаунт, календарь, диапазон), ключ — CTag:
не менялся → события парсятся с диска (боевым парсером CalDavClient),
REPORT-запросы в сеть не идут. Изменился → сеть + обновление кэша.
Хранение сырого XML вместо сериализации моделей — без хрупкого
JSON-маппинга ~20 полей события; чистка записей старше 30 дней.
CalDavClient.queryEventsInRange разделён на queryEventsRawXml +
parseEventsXml (поведение прежнее). Эффект: переключение месяцев,
периодический рефреш и возврат в календарь при неизменных календарях —
1 PROPFIND вместо 1+N REPORT. +5 unit-тестов кэша (JVM, TemporaryFolder).
2026-07-09 09:39:07 +00:00
b-dev-mobile 4c403e5ec2 changelog: контакты — инкрементальная синхронизация по CTag 2026-07-09 06:48:19 +00:00
b-dev-mobile 00daa980a8 contacts: инкрементальная синхронизация по CTag (Этап 2, DAV)
CardDavClient.collectionSignature() — дешёвый PROPFIND depth:1 за CTag книг
(расширение CalendarServer, Nextcloud; фолбэк sync-token), без address-data.
ContactsRepository.syncContacts: перед полной закачкой всех vCard сверяет
подпись коллекций; не менялось → отдаём кэш (Room), не качаем. Ускоряет
pull-to-refresh (раньше — полный PROPFIND ~500 vCard каждый раз).
+4 unit-теста на parseCollectionSignature (regex-парсер, без Android XmlPull).
2026-07-09 06:44:49 +00:00
b-dev-mobile 2709dba03f ci: паркуем CI до Docker-режима (host-исполнение act_runner 0.2.13 сломано)
Диагноз: диспетчеризация Gitea работает, но host-исполнение act_runner
роняет шаги (No such file / MODULE_NOT_FOUND, задвоение hostexecutor/.cache).
Workflow → workflow_dispatch (не auto-fail на push), раннер остановлен.
Рабочий путь на будущее — Docker-образ с JDK+SDK + gradle-кэш (docs/CI.md).
Сборки/релизы пока вручную (v0.5.123 опубликован).
2026-07-09 04:56:43 +00:00
b-dev-mobile c53885b230 ci: обход бага host-режима act_runner — checkout обычным git (без JS-экшенов)
CI / verify (push) Failing after 1s
2026-07-09 04:50:55 +00:00
b-dev-mobile a696d45f2e ci: тест после чистой регистрации раннера
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 04:19:35 +00:00
b-dev-mobile 2ae03264ff ci: восстановлен runs-on [self-hosted, f7-android] (метка не была причиной)
CI / verify (push) Failing after 4s
CI / release (push) Has been skipped
2026-07-09 04:12:34 +00:00
b-dev-mobile 47c274e441 ci: тест — runs-on одна метка f7-android
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 04:10:19 +00:00
b-dev-mobile 34bb16c59d ci: тест триггера после апгрейда раннера 0.2.13
CI / verify (push) Failing after 4s
CI / release (push) Has been skipped
2026-07-09 04:06:54 +00:00
b-dev-mobile 0fcc575e76 ci: пустой коммит для проверки триггера Actions
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped
2026-07-09 03:55:57 +00:00
108 changed files with 5073 additions and 1031 deletions
+17 -9
View File
@@ -1,14 +1,22 @@
name: CI
# Активируется, когда на инфре B зарегистрирован self-hosted act_runner с меткой `f7-android`
# (JDK 21 + Android SDK 36 — как на билд-машине). Пока раннера нет — джобы ждут исполнителя.
# Регистрация раннера — инфра-шаг ячейки B (см. docs/CI.md).
# ⏸️ ПАРКОВКА (2026-07-09): CI отложен. Раннер на инфре B развёрнут, но
# host-исполнение act_runner 0.2.13 в нашем окружении сломано (криво резолвит
# пути к шагам/JS-экшенам: MODULE_NOT_FOUND / "No such file", задвоение
# hostexecutor/.cache/act). Рабочий путь — Docker-исполнение с кастомным образом
# (JDK 21 + Android SDK 36) и volume для ~/.gradle. До этого — сборки/релизы вручную
# на билд-машине (см. docs/CI.md).
#
# Триггер временно ручной (workflow_dispatch), чтобы push'и не создавали падающие
# прогоны. Когда настроим Docker-раннер — вернуть блок on: push/pull_request ниже.
#
# on:
# push:
# branches: [main]
# tags: ['v*']
# pull_request:
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
env:
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
@@ -17,7 +25,7 @@ env:
jobs:
verify:
# lint + unit-тесты + debug-сборка на каждый push в main и на PR
# detekt + unit-тесты + Android Lint + debug-сборка на каждый push в main и на PR
runs-on: [self-hosted, f7-android]
if: github.ref_type != 'tag'
steps:
+1
View File
@@ -24,3 +24,4 @@
*.log
*.tmp
.cxx/
dist/
+10
View File
@@ -2,6 +2,16 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.1 — контакты): инкрементальная синхронизация по CTag. `CardDavClient.collectionSignature()` — дешёвый PROPFIND depth:1 за CTag адресных книг (расширение CalendarServer, поддерживается Nextcloud; фолбэк на sync-token), без address-data. `ContactsRepository.syncContacts` перед полной закачкой всех vCard сверяет подпись коллекций: не менялось → отдаём кэш (Room), сеть не грузим. Ускоряет pull-to-refresh (было — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста (regex-парсер подписи, без Android XmlPull). Дальше по Этапу 2: кэш событий календаря + CTag (сейчас экран календаря тянет события живьём по диапазону — под него сначала нужен кэш) | контракты не менялись | низкий: только оптимизация, поведение то же при изменениях
- 2026-07-09 | v0.5.123 (131) | Первый тегированный релиз в Gitea (тег `v0.5.123` + Release с APK и архивом исходников GPL). Включает хвосты этапов 0/1: FLAG_SECURE на экранах входа/PIN (аудит п.3, коммит cfa9fe2) + активированный CI-раннер на инфре B (docs/CI). Функциональных изменений в приложении относительно v0.5.122 нет, кроме FLAG_SECURE | контракты не менялись | низкий
- 2026-07-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI
+3 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26
targetSdk 36
versionCode 131
versionName '0.5.123'
versionCode 145
versionName '0.5.137'
missingDimensionStrategy 'default', 'f7'
multiDexEnabled true
@@ -128,6 +128,7 @@ dependencies {
implementation libs.firebase.messaging
implementation libs.coroutines.play.services
implementation libs.multidex
implementation libs.core.splashscreen
implementation libs.biometric
implementation libs.fragment.ktx
implementation libs.camera.camera2
+1
View File
@@ -37,6 +37,7 @@
android:resource="@mipmap/ic_launcher" />
<activity
android:name=".MainActivity"
android:theme="@style/Theme.F7.Splash"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
*/
class CallIncomingActivity : ComponentActivity() {
private var callEndedListener: ((String) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
finish()
}
// Звонок сняли извне (30-с таймаут «не берут трубку», принят/отклонён из
// уведомления) — закрываем полноэкранный входящий.
launch.roomToken?.takeIf { it.isNotBlank() }?.let { token ->
val listener: (String) -> Unit = { ended ->
if (ended == token) {
runOnUiThread {
if (!isFinishing) finish()
}
}
}
callEndedListener = listener
F7IncomingCallQueue.addCallEndedListener(listener)
}
setContent {
F7Theme {
IncomingCallScreen(
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
}
}
override fun onDestroy() {
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
callEndedListener = null
super.onDestroy()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
@@ -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 {
@@ -6,6 +6,7 @@ import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -43,6 +44,8 @@ class MainActivity : FragmentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
// Системный сплэш (фон + иконка) на холодном старте вместо белого экрана
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (!handleIncomingIntent(intent)) {
@@ -346,7 +346,14 @@ class OfficeEditorActivity : ComponentActivity() {
},
onPageReady = { view ->
runOnUiThread {
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)
}
@@ -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,
@@ -34,12 +35,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(null, "Заметки", "notes", webPath = "/apps/notes/"),
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
)
data class AppMenuExternalSite(
@@ -59,7 +64,7 @@ fun appMenuItems(
val core = coreAppMenuEntries.map { entry ->
F7AppMenuItem(
label = entry.label,
iconUrl = "$base/themes/forbion/images/header/${entry.iconPath}",
iconUrl = menuIcon(entry.iconPath),
selected = entry.tab == active,
externalUrl = entry.webPath?.let { "$base$it" },
)
@@ -72,19 +77,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
@@ -18,27 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -53,12 +38,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -68,34 +50,26 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import ru.forbion.f7cloud.mobile.BuildConfig
import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.auth.AuthVerifier
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.network.LoginFlowClient
import ru.forbion.f7cloud.core.push.F7PushEvent
import ru.forbion.f7cloud.core.push.F7PushEventHub
import ru.forbion.f7cloud.core.push.F7PushRegistrar
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
@@ -117,8 +91,8 @@ fun AppScaffold(
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
) {
val context = LocalContext.current
val authStore = remember { AuthStore(context) }
var session by remember { mutableStateOf(authStore.load()) }
val mainVm: MainViewModel = viewModel()
val session by mainVm.session.collectAsState()
var activeTab by rememberSaveable(
saver = Saver(
save = { state -> state.value.name },
@@ -160,6 +134,7 @@ fun AppScaffold(
var filesUploadRequest by remember { mutableIntStateOf(0) }
var contactsCreateRequest by remember { mutableIntStateOf(0) }
var tasksCreateRequest by remember { mutableIntStateOf(0) }
var tasksShowListsRequest by remember { mutableIntStateOf(0) }
var supportCreateRequest by remember { mutableIntStateOf(0) }
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
var talkChatsRequest by remember { mutableIntStateOf(0) }
@@ -173,17 +148,7 @@ fun AppScaffold(
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
}
val logoutScope = rememberCoroutineScope()
val forceLogout = {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
session?.let { current ->
logoutScope.launch { AppPasswordRevoker.revoke(current) }
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
authStore.clear()
session = null
}
val forceLogout = { mainVm.logout() }
LaunchedEffect(session?.serverUrl, session?.username) {
session?.let { OfficeWarmup.warm(it) }
}
@@ -196,10 +161,7 @@ fun AppScaffold(
F7Theme {
if (session == null) {
LoginScreen(onLogin = {
authStore.save(it)
session = it
})
LoginScreen(onLogin = { mainVm.login(it) })
return@F7Theme
}
@@ -497,7 +459,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 = {
@@ -506,10 +473,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 = {
@@ -603,6 +570,7 @@ fun AppScaffold(
modifier = contentModifier,
createRequest = tasksCreateRequest,
openListSlug = pendingTasksListSlug,
showListsRequest = tasksShowListsRequest,
onOpenListConsumed = { pendingTasksListSlug = null },
onUnauthorized = forceLogout,
)
@@ -688,237 +656,3 @@ fun AppScaffold(
}
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
@Composable
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
F7SecureScreen() // ввод пароля — не в скриншотах/recents
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
var loading by rememberSaveable { mutableStateOf(false) }
var error by rememberSaveable { mutableStateOf<String?>(null) }
val qrLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
?: return@rememberLauncherForActivityResult
scope.launch {
loading = true
error = null
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
if (loginResult == null) {
error = if (qrData.contains("/login/v2/flow/")) {
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
} else {
"Не удалось распознать QR-код"
}
loading = false
return@launch
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(loginResult.serverUrl),
username = loginResult.username,
appPassword = loginResult.appPassword,
trustAllCerts = trustAllCerts,
)
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа по QR" }
loading = false
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
}
}
fun launchQrScan() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
} else {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
fun submitLogin() {
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
return
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(serverUrl),
username = username.trim(),
appPassword = password,
trustAllCerts = trustAllCerts,
)
scope.launch {
loading = true
error = null
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа" }
loading = false
}
}
val logoUrl = remember(serverUrl) {
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
}
Box(
modifier = Modifier
.fillMaxSize()
.f7SafeTopInsets()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 24.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 440.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = logoUrl,
contentDescription = "Forbion",
modifier = Modifier
.width(300.dp)
.height(70.dp)
.padding(bottom = 48.dp),
contentScale = ContentScale.Fit,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = F7Colors.Background,
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Вход",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(bottom = 12.dp),
)
Text(
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(bottom = 8.dp),
)
F7OutlinedField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = "Адрес сервера",
modifier = Modifier
.focusRequester(serverFocus)
.focusProperties { next = usernameFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { usernameFocus.requestFocus() },
),
onEnter = { usernameFocus.requestFocus() },
)
F7OutlinedField(
value = username,
onValueChange = { username = it },
label = "Имя пользователя",
modifier = Modifier
.focusRequester(usernameFocus)
.focusProperties { next = passwordFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() },
),
onEnter = { passwordFocus.requestFocus() },
)
F7OutlinedField(
value = password,
onValueChange = { password = it },
label = "Пароль",
modifier = Modifier.focusRequester(passwordFocus),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
onGo = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
),
onEnter = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
)
Spacer(Modifier.height(8.dp))
F7PrimaryButton(
text = if (loading) "" else "Войти",
onClick = { submitLogin() },
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
)
F7TextButton(
text = "Сканировать QR для входа",
onClick = { launchQrScan() },
enabled = !loading,
modifier = Modifier.fillMaxWidth(),
)
if (loading) {
CircularProgressIndicator(color = F7Colors.Primary)
}
if (error != null) {
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
F7TextButton(
text = "Очистить",
onClick = {
error = null
password = ""
},
)
}
}
}
}
}
}
@@ -0,0 +1,312 @@
package ru.forbion.f7cloud.mobile.ui
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthVerifier
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
import ru.forbion.f7cloud.core.network.LoginFlowClient
import ru.forbion.f7cloud.mobile.BuildConfig
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
@Composable
internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
F7SecureScreen() // ввод пароля — не в скриншотах/recents
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
var loading by rememberSaveable { mutableStateOf(false) }
var error by rememberSaveable { mutableStateOf<String?>(null) }
val qrLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
?: return@rememberLauncherForActivityResult
scope.launch {
loading = true
error = null
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
if (loginResult == null) {
error = if (qrData.contains("/login/v2/flow/")) {
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
} else {
"Не удалось распознать QR-код"
}
loading = false
return@launch
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(loginResult.serverUrl),
username = loginResult.username,
appPassword = loginResult.appPassword,
trustAllCerts = trustAllCerts,
)
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа по QR" }
loading = false
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
}
}
fun launchQrScan() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
qrLauncher.launch(F7QrScannerActivity.intent(context))
} else {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
fun submitLogin() {
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
return
}
val newSession = AuthSession(
serverUrl = normalizeServerUrl(serverUrl),
username = username.trim(),
appPassword = password,
trustAllCerts = trustAllCerts,
)
scope.launch {
loading = true
error = null
AuthVerifier.verify(newSession)
.onSuccess { verified -> onLogin(verified) }
.onFailure { error = it.message ?: "Ошибка входа" }
loading = false
}
}
// Логотип — локальный (assets/login/big-forbion.svg из живой темы): виден офлайн и до
// ввода адреса сервера.
val logoUrl = "file:///android_asset/login/big-forbion.svg"
Box(
modifier = Modifier
.fillMaxSize()
// Живая тема логина: фон страницы --backgroud-color-main-darkgray
.background(Color(0xFFE5EFE8))
.f7SafeTopInsets()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 24.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 440.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = logoUrl,
contentDescription = "Forbion",
modifier = Modifier
.width(300.dp)
.height(70.dp)
.padding(bottom = 48.dp),
contentScale = ContentScale.Fit,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = F7Colors.Background,
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Вход",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(bottom = 12.dp),
)
Text(
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(bottom = 8.dp),
)
F7OutlinedField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = "Адрес сервера",
modifier = Modifier
.focusRequester(serverFocus)
.focusProperties { next = usernameFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { usernameFocus.requestFocus() },
),
onEnter = { usernameFocus.requestFocus() },
)
F7OutlinedField(
value = username,
onValueChange = { username = it },
label = "Имя пользователя",
modifier = Modifier
.focusRequester(usernameFocus)
.focusProperties { next = passwordFocus },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() },
),
onEnter = { passwordFocus.requestFocus() },
)
F7OutlinedField(
value = password,
onValueChange = { password = it },
label = "Пароль",
modifier = Modifier.focusRequester(passwordFocus),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrectEnabled = false,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
onGo = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
),
onEnter = {
focusManager.clearFocus()
keyboardController?.hide()
submitLogin()
},
)
Spacer(Modifier.height(8.dp))
F7PrimaryButton(
text = if (loading) "" else "Войти",
onClick = { submitLogin() },
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
)
// Живая тема: вторичная кнопка-ссылка 40dp, фон #F0F1F4, r8, текст 14 чёрный
Box(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFF0F1F4))
.clickable(enabled = !loading, onClick = { launchQrScan() }),
contentAlignment = Alignment.Center,
) {
Text(
"Сканировать QR для входа",
fontSize = 14.sp,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
)
}
if (loading) {
CircularProgressIndicator(color = F7Colors.Primary)
}
if (error != null) {
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
F7TextButton(
text = "Очистить",
onClick = {
error = null
password = ""
},
)
}
}
}
}
}
}
@@ -0,0 +1,41 @@
package ru.forbion.f7cloud.mobile.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
/**
* Держит session/auth-жизненный цикл верхнего уровня (вынесено из AppScaffold).
* В ViewModel сессия переживает пересоздание Activity (поворот и т.п.) — раньше
* жила в remember и перечитывалась из AuthStore.
*/
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val authStore = AuthStore(app)
private val _session = MutableStateFlow(authStore.load())
val session: StateFlow<AuthSession?> = _session.asStateFlow()
fun login(newSession: AuthSession) {
authStore.save(newSession)
_session.value = newSession
}
fun logout() {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
_session.value?.let { current ->
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
authStore.clear()
_session.value = null
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Логотип F7 для сплэша: вектор из темы forbion (logo-header.svg) вместо растянутого
растра лаунчер-иконки (мыло). Глиф (35×32, контент x 2.2633.53 / y 5.0125.99)
отцентрирован в центральных 2/3 квадрата 48 — безопасная зона круглой маски сплэша. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="48"
android:viewportHeight="48">
<group
android:scaleX="1.0233"
android:scaleY="1.0233"
android:translateX="5.69"
android:translateY="8.14">
<path
android:fillColor="#151515"
android:pathData="M8.33398 18.9808V25.9711C8.33398 25.9761 8.33209 25.981 8.32873 25.9845C8.32537 25.9881 8.3208 25.9901 8.31605 25.9901H2.27416C2.2718 25.9901 2.26947 25.9896 2.2673 25.9887C2.26512 25.9877 2.26314 25.9863 2.26148 25.9845C2.25812 25.981 2.25623 25.9761 2.25623 25.9711V5.02973C2.25623 5.02724 2.25669 5.02477 2.25759 5.02246C2.25849 5.02016 2.25981 5.01807 2.26148 5.0163C2.26484 5.01274 2.2694 5.01074 2.27416 5.01074H19.7382C19.7415 5.01074 19.7447 5.01166 19.7475 5.01343C19.7503 5.0152 19.7526 5.01773 19.7541 5.02076C19.7556 5.0238 19.7564 5.02722 19.7562 5.03065C19.756 5.03409 19.755 5.03742 19.7532 5.04028L16.2595 10.7647C16.2578 10.7674 16.2555 10.7696 16.2528 10.771C16.2501 10.7725 16.2471 10.7732 16.244 10.7731H8.35192C8.34716 10.7731 8.3426 10.7751 8.33924 10.7787C8.33587 10.7823 8.33398 10.7871 8.33398 10.7921V13.1672C8.33398 13.1723 8.33587 13.1771 8.33924 13.1807C8.3426 13.1842 8.34716 13.1862 8.35192 13.1862H14.9303C14.9328 13.1862 14.9352 13.1867 14.9374 13.1878C14.9397 13.1888 14.9417 13.1903 14.9434 13.1922C14.9451 13.194 14.9464 13.1963 14.9472 13.1987C14.948 13.2011 14.9484 13.2037 14.9483 13.2063L14.6937 18.9439C14.6935 18.9487 14.6915 18.9533 14.6881 18.9566C14.6848 18.96 14.6804 18.9618 14.6758 18.9618H8.35192C8.34716 18.9618 8.3426 18.9638 8.33924 18.9674C8.33587 18.9709 8.33398 18.9758 8.33398 18.9808Z" />
<path
android:fillColor="#70B62B"
android:pathData="M26.2675 10.8048C26.2877 10.7837 26.2836 10.7731 26.255 10.7731H17.7975C17.7762 10.7731 17.7714 10.7638 17.783 10.7452L21.3221 5.03395C21.3317 5.01848 21.3453 5.01074 21.3629 5.01074H33.5155C33.5188 5.01074 33.5219 5.01213 33.5243 5.0146C33.5266 5.01708 33.5279 5.02043 33.5279 5.02393V10.7494C33.5279 10.7673 33.5218 10.7826 33.5095 10.7953C31.0839 13.3392 28.8027 16.2518 27.1169 19.4365C26.3193 20.944 25.6677 22.6434 25.4834 24.3571C25.4296 24.8592 25.4445 25.4268 25.445 25.9632C25.445 25.9811 25.4367 25.9901 25.4201 25.9901H18.4337C18.4164 25.9901 18.4072 25.981 18.4063 25.9627C18.3571 24.9535 18.4315 23.9955 18.6294 23.0886C19.0753 21.0468 20.0567 19.0346 21.1572 17.2724C22.6248 14.9221 24.3809 12.7806 26.2675 10.8048Z" />
</group>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Сплэш при холодном старте (androidx core-splashscreen): фон в цвет
F7Colors.Background + векторный логотип F7 (лаунчер-иконка — растянутый
растр, мылится на сплэше); после — прежняя тема. -->
<style name="Theme.F7.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FBFBFB</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/f7_splash_logo</item>
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
</style>
</resources>
@@ -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,20 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -53,13 +52,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 +108,8 @@ fun F7AppMenuSheet(
),
)
}
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень
// Сам лист — выезжает снизу вверх (translateY 100%→0), как в живом forbion:
// без скругления верха и без ручки, высота по контенту до почти всего экрана.
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
@@ -126,60 +125,46 @@ 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 ->
filteredItems.chunked(3).forEach { rowItems ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
) {
rowItems.forEach { item ->
val originalIndex = items.indexOf(item)
Box(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f),
contentAlignment = Alignment.TopCenter,
) {
F7AppMenuGridItem(
item = item,
onClick = {
if (originalIndex >= 0) {
onItemClick(originalIndex)
}
},
onClick = { if (originalIndex >= 0) onItemClick(originalIndex) },
)
}
}
// добить ряд пустыми ячейками для выравнивания по левому краю
repeat(3 - rowItems.size) { Box(modifier = Modifier.weight(1f)) {} }
}
}
}
}
}
@@ -251,32 +236,35 @@ private fun F7AppMenuGridItem(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val localIcon = item.localIcon
if (localIcon != null) {
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
// Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
// как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
val frameShape = CircleShape
Box(
modifier = Modifier
.size(MenuIconSize)
.clip(CircleShape)
.shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
.clip(frameShape)
.background(Color.White)
.border(1.5.dp, F7Colors.Green30, CircleShape),
.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),
modifier = Modifier.size(MenuIconSize * 0.56f),
contentScale = ContentScale.Fit,
)
}
}
Text(
text = item.label,
style = MaterialTheme.typography.labelLarge.copy(
@@ -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,
),
@@ -42,9 +42,11 @@ object F7Colors {
val SecondaryButtonBg = Color(0xFFFDFDFD)
val SecondaryButtonBorder = Grey3
// Дизайн-красный #FF7A66 (был #D74642 — расходился с макетом)
val Error = Red
val ErrorBg = Red10
// Семантика ошибок — по боевой теме forbion (_base.css: --color-element-error /
// --color-text-error / --color-error); базовый Red #FF7A66 из брендбука остаётся для тегов.
val Error = Color(0xFFD74642)
val ErrorText = Color(0xFFD42722)
val ErrorBg = Color(0xFFFFE2E2)
val StatusNew = Color(0xFF2B9AB6)
val StatusProgress = Color(0xFF70B62B)
@@ -16,8 +16,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -43,11 +46,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
/**
@@ -69,6 +75,46 @@ fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () ->
}
}
/**
* Единый экран ошибки (по центру): иконка + сообщение + кнопка «Повторить».
* Используется везде, где загрузка упала и данных нет — вместо россыпи красного текста.
*/
@Composable
fun F7ErrorState(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(F7Colors.Error.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Text("!", color = F7Colors.Error, fontSize = 30.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(12.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
textAlign = TextAlign.Center,
)
if (onRetry != null) {
Spacer(Modifier.height(16.dp))
F7SecondaryButton(text = "Повторить", onClick = onRetry)
}
}
}
@Composable
fun F7ModuleScreen(
title: String? = null,
@@ -76,6 +122,9 @@ fun F7ModuleScreen(
loading: Boolean = false,
error: String? = null,
onRefresh: (() -> Unit)? = null,
// Действие «Повторить» в экране ошибки; по умолчанию — как onRefresh.
// Позволяет дать retry, не показывая хедер-кнопку ↻ (когда onRefresh не задан).
onErrorRetry: (() -> Unit)? = onRefresh,
headerActions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
@@ -126,8 +175,15 @@ fun F7ModuleScreen(
CircularProgressIndicator(color = F7Colors.Primary)
}
if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
// Ошибка без данных → центрированный экран с «Повторить»
F7ErrorState(
message = error,
onRetry = onErrorRetry,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -136,6 +192,7 @@ fun F7ModuleScreen(
)
}
}
}
@Composable
fun F7PrimaryButton(
@@ -9,7 +9,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -29,22 +30,29 @@ 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 +70,11 @@ fun F7MobileBottomBar(
modifier: Modifier = Modifier,
) {
val base = serverUrl.trimEnd('/')
val pillShape = RoundedCornerShape(percent = 50)
val pillShape = RoundedCornerShape(100.dp)
Row(
modifier = modifier
.wrapContentWidth()
.width(BottomBarWidth)
.height(BottomBarHeight)
.shadow(
elevation = 2.dp,
shape = pillShape,
@@ -73,11 +82,8 @@ fun F7MobileBottomBar(
)
.clip(pillShape)
.background(Color(0xFFF5F5F5))
.padding(
horizontal = BottomBarOuterPaddingH,
vertical = BottomBarOuterPaddingV,
),
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
.padding(BottomBarOuterPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
config.slots.forEach { slot ->
@@ -89,39 +95,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 +143,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(
@@ -22,6 +22,9 @@ data class DavCalendar(
val href: String,
val displayName: String,
val color: String? = null,
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
val ctag: String = "",
)
data class DavEvent(
@@ -72,6 +75,8 @@ data class DavTask(
val description: String,
val calendarName: String,
val calendarHref: String,
/** UID родительской задачи (RELATED-TO;RELTYPE=PARENT) — для подзадач; пусто у корневых. */
val parentUid: String = "",
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
@@ -84,6 +89,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)
@@ -103,7 +113,7 @@ object CalDavClient {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/></d:prop>
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, baseUrl, depth = 1, body)
@@ -129,7 +139,15 @@ object CalDavClient {
calendar: DavCalendar,
rangeStart: Instant,
rangeEnd: Instant,
): List<DavEvent> {
): List<DavEvent> = parseEventsXml(queryEventsRawXml(client, calendar, rangeStart, rangeEnd), calendar)
/** Сырой REPORT-ответ (для кэширования на диске с ключом по CTag). */
fun queryEventsRawXml(
client: OkHttpClient,
calendar: DavCalendar,
rangeStart: Instant,
rangeEnd: Instant,
): String {
val startStr = formatCalDavTime(rangeStart)
val endStr = formatCalDavTime(rangeEnd)
val body = """
@@ -146,10 +164,13 @@ object CalDavClient {
</c:calendar-query>
""".trimIndent()
val href = calendar.href.trimEnd('/') + "/"
val xml = report(client, href, body)
return parseCalendarQueryResponses(xml, calendar)
return report(client, href, body)
}
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
parseCalendarQueryResponses(xml, calendar)
fun createEvent(
client: OkHttpClient,
calendar: DavCalendar,
@@ -620,6 +641,7 @@ object CalDavClient {
dueDate: LocalDate? = null,
priority: Int = 0,
description: String = "",
parentUid: String = "",
): DavTask {
val uid = "${UUID.randomUUID()}@f7cloud.mobile"
val ics = buildVTodoIcs(
@@ -630,6 +652,7 @@ object CalDavClient {
priority = priority,
percentComplete = 0,
description = description,
parentUid = parentUid,
)
val url = calendar.href.trimEnd('/') + "/$uid.ics"
val req = Request.Builder()
@@ -656,6 +679,7 @@ object CalDavClient {
description = description,
calendarName = calendar.displayName,
calendarHref = calendar.href,
parentUid = parentUid,
)
}
@@ -677,6 +701,7 @@ object CalDavClient {
priority = priority,
percentComplete = percentComplete,
description = description,
parentUid = task.parentUid, // не терять связь с родителем при правке подзадачи
)
val req = Request.Builder()
.url(task.href)
@@ -782,6 +807,7 @@ object CalDavClient {
var href = ""
var displayName = ""
var calendarColor: String? = null
var ctag = ""
var isCollection = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
@@ -791,11 +817,13 @@ object CalDavClient {
href = ""
displayName = ""
calendarColor = null
ctag = ""
isCollection = false
}
"collection" -> if (inResponse) isCollection = true
"displayname" -> if (inResponse) displayName = parser.readText().trim()
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
"getctag" -> if (inResponse) ctag = parser.readText().trim()
"href" -> if (inResponse) href = parser.readText().trim()
}
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
@@ -809,7 +837,7 @@ object CalDavClient {
val name = displayName.ifBlank {
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
}
out += DavCalendar(href = full, displayName = name, color = calendarColor)
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
}
}
inResponse = false
@@ -921,6 +949,7 @@ object CalDavClient {
description = extractIcs(icsDescription, ics),
calendarName = calendarName,
calendarHref = calendarHref,
parentUid = extractIcs(icsRelatedTo, ics).trim(),
)
}
@@ -932,6 +961,7 @@ object CalDavClient {
priority: Int,
percentComplete: Int,
description: String,
parentUid: String = "",
): String {
val now = Instant.now()
return buildString {
@@ -959,6 +989,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")
}
@@ -69,6 +69,53 @@ object CardDavClient {
return out.distinctBy { "${it.uid}|${it.email}" }
}
/**
* Дешёвая «подпись» состояния адресных книг — CTag коллекций (расширение
* CalendarServer, поддерживается Nextcloud), с фолбэком на sync-token.
* Один PROPFIND depth:1 без address-data. Если подпись не изменилась с прошлой
* синхронизации — контакты качать не нужно. null при ошибке/неподдержке → полный sync.
*/
fun collectionSignature(
client: OkHttpClient,
serverUrl: String,
userId: String,
): String? = runCatching {
val base = davAddressBooksBaseUrl(serverUrl, userId)
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:prop><cs:getctag/><d:sync-token/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, base, depth = 1, body)
parseCollectionSignature(xml).ifBlank { null }
}.getOrNull()
/**
* Парсит PROPFIND-ответ в стабильную подпись «href=ctag» (по книгам, отсортировано).
* Regex-парсинг (как ICS в проекте) — чистая функция, тестируется без Android XmlPull.
*/
internal fun parseCollectionSignature(xml: String): String {
val entries = sortedSetOf<String>()
for (m in responseBlockPattern.findAll(xml)) {
val block = m.value
val href = hrefTagPattern.find(block)?.groupValues?.get(1)?.trim().orEmpty()
val tag = (
getctagPattern.find(block)?.groupValues?.get(1)
?: syncTokenPattern.find(block)?.groupValues?.get(1)
)?.trim().orEmpty()
if (href.isNotBlank() && tag.isNotBlank()) {
entries += "$href=$tag"
}
}
return entries.joinToString("\n")
}
private val responseBlockPattern = Regex("(?is)<(?:\\w+:)?response\\b.*?</(?:\\w+:)?response>")
private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)</(?:\\w+:)?href>")
private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)</(?:\\w+:)?getctag>")
private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)</(?:\\w+:)?sync-token>")
fun createContact(
client: OkHttpClient,
serverUrl: String,
@@ -0,0 +1,56 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Тесты подписи коллекций CardDAV (CTag/sync-token) — основа инкрементальной
* синхронизации: если подпись не изменилась, контакты не перекачиваются.
*/
class CardDavSignatureTest {
private val ns = """xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/""""
private fun body(vararg responses: String) =
"""<?xml version="1.0"?><d:multistatus $ns>${responses.joinToString("")}</d:multistatus>"""
private fun resp(href: String, ctag: String? = null, token: String? = null) = """
<d:response>
<d:href>$href</d:href>
<d:propstat><d:prop>
${ctag?.let { "<cs:getctag>$it</cs:getctag>" } ?: ""}
${token?.let { "<d:sync-token>$it</d:sync-token>" } ?: ""}
</d:prop></d:propstat>
</d:response>
""".trimIndent()
@Test fun ctag_signature_is_stable_and_sorted() {
val a = CardDavClient.parseCollectionSignature(
body(resp("/dav/addressbooks/u/b1/", ctag = "111"), resp("/dav/addressbooks/u/b2/", ctag = "222")),
)
// порядок ответов не влияет на подпись
val b = CardDavClient.parseCollectionSignature(
body(resp("/dav/addressbooks/u/b2/", ctag = "222"), resp("/dav/addressbooks/u/b1/", ctag = "111")),
)
assertEquals(a, b)
assertTrue(a.contains("/dav/addressbooks/u/b1/=111"))
assertTrue(a.contains("/dav/addressbooks/u/b2/=222"))
}
@Test fun ctag_change_changes_signature() {
val before = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "111")))
val after = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "999")))
assertNotEquals(before, after)
}
@Test fun sync_token_fallback_when_no_ctag() {
val sig = CardDavClient.parseCollectionSignature(body(resp("/b1/", token = "http://sabre/sync/42")))
assertEquals("/b1/=http://sabre/sync/42", sig)
}
@Test fun empty_when_no_tags() {
assertEquals("", CardDavClient.parseCollectionSignature(body(resp("/b1/"))))
}
}
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Handles Decline on incoming Talk call notifications. */
/** Handles Decline and ring-timeout on incoming Talk call notifications. */
class F7CallActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
when (intent.action) {
ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
}
}
companion object {
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
const val ACTION_CALL_TIMEOUT = "ru.forbion.f7cloud.action.CALL_TIMEOUT"
const val EXTRA_NOTIFICATION_ID = "notificationId"
const val EXTRA_ROOM_TOKEN = "roomToken"
}
@@ -1,10 +1,13 @@
package ru.forbion.f7cloud.core.push
import android.app.AlarmManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
private const val KEY_QUEUE = "queue"
private const val KEY_ACTIVE_TOKEN = "active_token"
private const val KEY_ACTIVE_AT = "active_at"
private const val KEY_ACTIVE_CALL = "active_call"
const val ACTIVE_NOTIFICATION_ID = 5000
private const val MISSED_NOTIFICATION_BASE = 5100
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
/** Входящий звонит 30 с; дальше — авто-сброс и уведомление «Пропущенный звонок». */
private const val RING_TIMEOUT_MS = 30_000L
/** Запас страховочного alarm поверх основного in-process таймера. */
private const val TIMEOUT_ALARM_SLACK_MS = 5_000L
private val lock = Any()
private val handler = Handler(Looper.getMainLooper())
private var timeoutRunnable: Runnable? = null
private val callEndedListeners = java.util.concurrent.CopyOnWriteArraySet<(String) -> Unit>()
/** Уведомляет UI (полноэкранный входящий), что активный звонок снят: принят/отклонён/протух. */
fun addCallEndedListener(listener: (String) -> Unit) {
callEndedListeners.add(listener)
}
fun removeCallEndedListener(listener: (String) -> Unit) {
callEndedListeners.remove(listener)
}
private fun notifyCallEnded(token: String) {
callEndedListeners.forEach { listener ->
runCatching { listener(token) }
}
}
fun enqueue(
context: Context,
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
}
if (active.isEmpty()) {
setActive(prefs, token)
val shown = showNotification(context, call, waiting = 0)
if (!shown) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
if (shown) {
setActive(prefs, call)
scheduleTimeout(context, token)
}
return shown
}
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
}
fun dismissAndShowNext(context: Context, roomToken: String?) {
var ended: String? = null
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
@@ -88,14 +118,60 @@ object F7IncomingCallQueue {
}
}
cancelTimeout(context)
cancelNotification(context)
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
clearActive(prefs)
ended = active.takeIf { it.isNotEmpty() }
advanceQueueLocked(context, prefs, queue)
}
ended?.let { notifyCallEnded(it) }
}
/**
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
* Принятые/отклонённые звонки сюда не попадают — их снимает [dismissAndShowNext].
*/
fun timeoutActive(context: Context, roomToken: String?) {
var ended: String? = null
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
if (active.isEmpty()) return
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
val call = readActiveCall(prefs)
cancelTimeout(context)
cancelNotification(context)
clearActive(prefs)
ended = active
Log.i(TAG, "Call timed out (unanswered): $active")
if (call != null) {
showMissedNotification(context, call)
}
advanceQueueLocked(context, prefs, readQueue(prefs))
}
ended?.let { notifyCallEnded(it) }
}
fun clearAll(context: Context) {
synchronized(lock) {
val prefs = prefs(context)
cancelTimeout(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
}
}
private fun advanceQueueLocked(
context: Context,
prefs: android.content.SharedPreferences,
queue: JSONArray,
) {
if (queue.length() == 0) {
prefs.edit().remove(KEY_QUEUE).apply()
return
}
runCatching {
val next = PendingCall.fromJson(queue.getJSONObject(0))
val rest = JSONArray()
@@ -104,22 +180,14 @@ object F7IncomingCallQueue {
}
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
if (showNotification(context, next, rest.length())) {
setActive(prefs, next.roomToken)
setActive(prefs, next)
scheduleTimeout(context, next.roomToken)
}
}.onFailure {
Log.w(TAG, "Failed to parse queued call", it)
prefs.edit().remove(KEY_QUEUE).apply()
}
}
}
fun clearAll(context: Context) {
synchronized(lock) {
val prefs = prefs(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
}
}
private fun showNotification(
context: Context,
@@ -343,13 +411,106 @@ object F7IncomingCallQueue {
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
private fun setActive(prefs: android.content.SharedPreferences, call: PendingCall) {
prefs.edit()
.putString(KEY_ACTIVE_TOKEN, token)
.putString(KEY_ACTIVE_TOKEN, call.roomToken)
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
// показать «Пропущенный» с названием комнаты.
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
.apply()
}
private fun clearActive(prefs: android.content.SharedPreferences) {
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
}
private fun readActiveCall(prefs: android.content.SharedPreferences): PendingCall? {
val raw = prefs.getString(KEY_ACTIVE_CALL, null) ?: return null
return runCatching { PendingCall.fromJson(JSONObject(raw)) }.getOrNull()
}
private fun scheduleTimeout(context: Context, token: String) {
val app = context.applicationContext
timeoutRunnable?.let(handler::removeCallbacks)
val runnable = Runnable { timeoutActive(app, token) }
timeoutRunnable = runnable
handler.postDelayed(runnable, RING_TIMEOUT_MS)
// Страховка: если процесс умрёт до срабатывания таймера, alarm поднимет его и
// заменит зависшее уведомление звонка на «Пропущенный». timeoutActive идемпотентен.
runCatching {
app.getSystemService(AlarmManager::class.java)?.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + RING_TIMEOUT_MS + TIMEOUT_ALARM_SLACK_MS,
timeoutAlarmIntent(app, token),
)
}.onFailure { Log.w(TAG, "Timeout alarm schedule failed", it) }
}
private fun cancelTimeout(context: Context) {
timeoutRunnable?.let(handler::removeCallbacks)
timeoutRunnable = null
runCatching {
context.applicationContext.getSystemService(AlarmManager::class.java)
?.cancel(timeoutAlarmIntent(context.applicationContext, null))
}
}
private fun timeoutAlarmIntent(context: Context, token: String?): PendingIntent {
val intent = Intent(context, F7CallActionReceiver::class.java).apply {
action = F7CallActionReceiver.ACTION_CALL_TIMEOUT
if (token != null) {
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, token)
}
}
return PendingIntent.getBroadcast(
context,
ACTIVE_NOTIFICATION_ID + 2,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun showMissedNotification(context: Context, call: PendingCall) {
F7NotificationChannels.ensureAll(context)
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val roomName = call.displayName.ifBlank { call.title }
val notificationId = MISSED_NOTIFICATION_BASE + kotlin.math.abs(call.roomToken.hashCode() % 1000)
val pending = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
PendingIntent.getActivity(
context,
notificationId,
Intent(launch).putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.sym_call_missed
// Локскрин — обезличенно (минимум информации, решение владельца); название комнаты
// видно после разблокировки.
val publicVersion = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
.setSmallIcon(iconRes)
.setContentTitle("F7cloud")
.setContentText(context.getString(R.string.call_missed_public))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.apply { if (pending != null) setContentIntent(pending) }
.build()
val notification = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
.setSmallIcon(iconRes)
.setContentTitle(context.getString(R.string.call_missed_title))
.setContentText(roomName)
.setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.apply { if (pending != null) setContentIntent(pending) }
.build()
runCatching { manager.notify(notificationId, notification) }
.onFailure { Log.w(TAG, "Missed-call notification failed", it) }
}
private fun touchActive(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
}
@@ -360,7 +521,7 @@ object F7IncomingCallQueue {
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
Log.w(TAG, "Clearing stale active call: $active")
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
clearActive(prefs)
}
}
@@ -5,6 +5,8 @@
<string name="call_action_accept">Принять</string>
<string name="call_action_decline">Отклонить</string>
<string name="incoming_call_subtitle">F7cloud звонок</string>
<string name="call_missed_title">Пропущенный звонок</string>
<string name="call_missed_public">Вы пропустили звонок</string>
<plurals name="call_queue_waiting">
<item quantity="one">Ещё %d звонок в очереди</item>
<item quantity="few">Ещё %d звонка в очереди</item>
+20 -5
View File
@@ -48,9 +48,24 @@ scripts/package-source.sh dist/ # → dist/f7cloud-mobile-v<ver>-<rev>-sourc
В CI прикладывается к каждому Gitea Release. До появления раннера — публиковать исходник
вручную этим скриптом к каждой распространяемой сборке.
## Статус
## Статус — ⏸️ ОТЛОЖЕНО (2026-07-09)
**Активно (2026-07-09):** self-hosted раннер `f7-b-android` зарегистрирован и запущен на инфре B
(scope — репозиторий, host-исполнение). Workflow срабатывает на push/PR (lint+тесты+assembleDebug).
Осталось для джобы `release` по тегу: завести Secrets репозитория (keystore base64 + пароли)
пока release-сборки делаются вручную на билд-машине тем же ключом.
Раннер развёрнут на инфре B (act_runner 0.2.13 + Node 20 + Docker + systemd), диспетчеризация
Gitea работает (джобы доходят до раннера). **НО host-исполнение act_runner 0.2.13 в нашем
окружении сломано**: криво резолвит путь к шагам и JS-экшенам
`No such file or directory` на `.cache/act/<id>/act/workflow/0.sh` и `MODULE_NOT_FOUND` на
`actions/checkout` (задвоение `hostexecutor/.cache/act`). Обойти на уровне workflow нельзя
(ломается сам запуск шага). Workflow **запаркован** (триггер `workflow_dispatch`), сервис
раннера остановлен и отключён от автозапуска — чтобы push'и не плодили падающие прогоны.
**Рабочий путь (когда вернёмся):** Docker-исполнение вместо host —
- кастомный образ с JDK 21 + Android SDK 36 (в дефолтном ubuntu их нет);
- volume для тёплого `~/.gradle` (vendor/WebRTC);
- раннер с меткой на docker-образ (не `:host`); в docker-режиме пути корректны, JS-экшены работают;
- регистрировать repo-токеном именно из **`f7cloud_mobile`** (id 24) — при отладке «repo-токены»
ошибочно регистрировали к чужому `f7cloud_client` (id 4) или в user-scope; в Gitea осталось
~5 дублей раннера `f7-b-android` (удалить в админке).
**Пока:** сборки и релизы — **вручную** на билд-машине (боевой ключ владельца из
`/root/.f7cloud-keys/`), исходники — `scripts/package-source.sh`, Release в Gitea — через API.
Что и делаем: релиз `v0.5.123` опубликован (APK + source) вручную.
+1
View File
@@ -38,4 +38,5 @@ dependencies {
implementation libs.core.ktx
implementation libs.coil.compose
implementation libs.coil.svg
testImplementation libs.junit
}
@@ -392,41 +392,46 @@ fun CalendarMonthGrid(
) {
val days = CalendarRepository.monthGridDays(month)
val today = LocalDate.now()
// Живая тема (_calendar-month-view-mobile.css): месяц — белая карточка r12 с рамкой,
// внутри дни недели — круги 40 (#F5F5F5, 16/500) и ячейки-карточки #FDFDFD r4
// с тенью, зазор 4.
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Заголовок дней недели — серые пилюли (по макету «Календарь · Месяц»)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
weekDayLabels.forEach { label ->
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Grey2)
.padding(vertical = 6.dp),
.size(40.dp)
.clip(CircleShape)
.background(F7Colors.Grey2),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextSecondary,
color = F7Colors.TextPrimary,
)
}
}
}
// Гридлайн-сетка без скруглений (по макету)
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, F7Colors.Grey3),
) {
}
days.chunked(7).forEach { week ->
Row(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
week.forEach { day ->
val inMonth = day.month == month.month
val dayEvents = eventsByDay[day].orEmpty()
@@ -444,7 +449,6 @@ fun CalendarMonthGrid(
}
}
}
}
@Composable
private fun CalendarDayCell(
@@ -456,45 +460,55 @@ private fun CalendarDayCell(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val cellBg = when {
isToday -> F7Colors.Green10
selected -> F7Colors.PrimaryLight
else -> F7Colors.Surface
}
// Живая тема: ячейка — карточка #FDFDFD r4 с тонкой тенью, высота 120, паддинг 4;
// номер дня 14/500 СПРАВА-сверху (чужой месяц — серый); «сегодня» — зелёная плашка
// 40×24 r4 с белым числом; выбранный день подсвечиваем #ECF9DE (наша адаптация тапа).
Column(
modifier = modifier
.heightIn(min = 66.dp)
.border(0.5.dp, F7Colors.Grey3)
.background(cellBg)
.height(120.dp)
.shadow(
elevation = 1.dp,
shape = RoundedCornerShape(4.dp),
spotColor = F7Colors.Border,
)
.clip(RoundedCornerShape(4.dp))
.background(if (selected) F7Colors.PrimaryLight else Color(0xFFFDFDFD))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 4.dp),
.padding(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// Номер дня сверху-слева; сегодня — в зелёном круге
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
if (isToday) {
Box(
modifier = Modifier
.size(22.dp)
.then(if (isToday) Modifier.clip(CircleShape).background(F7Colors.Primary) else Modifier),
.width(40.dp)
.height(24.dp)
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.Primary),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (isToday) FontWeight.SemiBold else FontWeight.Normal,
color = when {
isToday -> F7Colors.TextOnPrimary
!inMonth -> F7Colors.TextMuted
else -> F7Colors.TextPrimary
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = Color.White,
)
}
if (inMonth) {
events.take(2).forEach { event ->
CalendarDayEventPill(event.summary.ifBlank { "Событие" })
}
if (events.size > 2) {
} else {
Text(
"+${events.size - 2}",
text = day.toString(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (inMonth) F7Colors.TextPrimary else F7Colors.TextSecondary,
)
}
}
events.take(3).forEach { event ->
CalendarDayEventPill(event.summary.ifBlank { "Событие" }, muted = !inMonth)
}
if (events.size > 3) {
Text(
"+${events.size - 3}",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.Medium,
@@ -502,19 +516,23 @@ private fun CalendarDayCell(
}
}
}
}
@Composable
private fun CalendarDayEventPill(text: String) {
private fun CalendarDayEventPill(text: String, muted: Boolean = false) {
// Живая тема: пилюля события — фон #ECF9DE, рамка #70B62B, r4, текст 10/12;
// вне текущего месяца — серые с белым текстом.
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.PrimaryLight)
.padding(horizontal = 4.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.PrimaryDark,
.background(if (muted) F7Colors.TextSecondary else F7Colors.PrimaryLight)
.border(1.dp, if (muted) F7Colors.TextSecondary else F7Colors.Primary, RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
fontSize = 10.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Medium,
color = if (muted) Color.White else F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -811,12 +829,14 @@ fun CalendarEventRow(
@Composable
private fun CalendarEventChip(event: CalendarEventItem, onClick: () -> Unit) {
// Живая тема: пилюля события — #ECF9DE с рамкой #70B62B, r4, текст 12/16
Text(
event.summary,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.background(F7Colors.Primary.copy(alpha = 0.15f))
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.PrimaryLight)
.border(1.dp, F7Colors.Primary, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
@@ -1075,7 +1095,13 @@ fun CalendarEventDetailSheet(
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(event.summary, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
// Живая тема: заголовки модалок 18/600
Text(
event.summary,
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
CalendarDetailRow(
iconUrl = "$base/themes/forbion/images/calendar/event-clock.svg",
label = "Время",
@@ -0,0 +1,58 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
import java.io.File
import java.security.MessageDigest
/**
* Дисковый кэш «сырых» REPORT-ответов CalDAV (события по диапазону), ключуемый
* CTag'ом календаря: CTag не изменился → события читаются с диска, сеть не трогаем.
*
* Храним именно сырой XML (а не сериализованные модели) — переиспользуем боевой
* парсер CalDavClient и не заводим хрупкий JSON-маппинг ~20 полей события.
* Формат файла: первая строка — CTag на момент загрузки, дальше XML.
*/
class CalendarEventsCache(private val dir: File) {
constructor(context: Context) : this(File(context.cacheDir, "caldav_events"))
/** XML событий, если кэш есть и его CTag совпадает с текущим; иначе null. */
fun get(accountKey: String, calendarHref: String, rangeKey: String, ctag: String): String? {
if (ctag.isBlank()) return null
val f = entryFile(accountKey, calendarHref, rangeKey)
if (!f.isFile) return null
return runCatching {
val text = f.readText()
val nl = text.indexOf('\n')
if (nl > 0 && text.substring(0, nl) == ctag) text.substring(nl + 1) else null
}.getOrNull()
}
fun put(accountKey: String, calendarHref: String, rangeKey: String, ctag: String, xml: String) {
if (ctag.isBlank()) return
runCatching {
dir.mkdirs()
entryFile(accountKey, calendarHref, rangeKey).writeText("$ctag\n$xml")
prune()
}
}
/** Чистка устаревших записей (диапазоны уезжают со временем — файлы копятся). */
private fun prune(maxAgeMillis: Long = MAX_AGE_MILLIS) {
val now = System.currentTimeMillis()
dir.listFiles()?.forEach { f ->
if (now - f.lastModified() > maxAgeMillis) f.delete()
}
}
private fun entryFile(accountKey: String, calendarHref: String, rangeKey: String): File =
File(dir, md5("$accountKey|$calendarHref|$rangeKey") + ".xml")
private fun md5(s: String): String =
MessageDigest.getInstance("MD5").digest(s.toByteArray())
.joinToString("") { "%02x".format(it) }
private companion object {
const val MAX_AGE_MILLIS = 30L * 24 * 3600 * 1000 // 30 дней
}
}
@@ -22,6 +22,8 @@ import java.time.ZoneOffset
class CalendarRepository(
private val apiClient: CalendarApiClient = CalendarApiClient(),
// Кэш событий по CTag (null — без кэша, поведение как раньше)
private val eventsCache: CalendarEventsCache? = null,
) {
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
@@ -38,7 +40,7 @@ class CalendarRepository(
val ctx = openDavContext(session)
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs)
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs, accountKey(session))
}
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
@@ -210,13 +212,25 @@ class CalendarRepository(
rangeStart: Instant,
rangeEnd: Instant,
visibleHrefs: Set<String>,
accountKey: String,
): List<CalendarEventItem> {
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
val rangeKey = "${rangeStart.epochSecond}-${rangeEnd.epochSecond}"
val events = mutableListOf<DavEvent>()
var successCount = 0
var lastError: String? = null
for (cal in calendars.take(12)) {
runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) }
runCatching {
// CTag календаря не менялся → сырой REPORT-ответ берём с диска, сеть не трогаем
val cachedXml = eventsCache?.get(accountKey, cal.href, rangeKey, cal.ctag)
if (cachedXml != null) {
CalDavClient.parseEventsXml(cachedXml, cal)
} else {
val xml = CalDavClient.queryEventsRawXml(ctx.client, cal, rangeStart, rangeEnd)
eventsCache?.put(accountKey, cal.href, rangeKey, cal.ctag, xml)
CalDavClient.parseEventsXml(xml, cal)
}
}
.onSuccess { successCount++; events += it }
.onFailure { lastError = it.message }
}
@@ -226,6 +240,9 @@ class CalendarRepository(
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
}
private fun accountKey(session: AuthSession): String =
"${session.serverUrl}|${session.username}"
private fun DavEvent.toItem() = CalendarEventItem(
uid = uid,
href = href,
@@ -62,7 +62,9 @@ data class CalendarUiState(
class CalendarViewModel(
context: Context,
private val repository: CalendarRepository = CalendarRepository(),
private val repository: CalendarRepository = CalendarRepository(
eventsCache = CalendarEventsCache(context.applicationContext),
),
) : ViewModel() {
private val appContext = context.applicationContext
private val deviceClient = DeviceCalendarClient(appContext)
@@ -0,0 +1,54 @@
package ru.forbion.f7cloud.feature.calendar
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Кэш «сырых» REPORT-ответов CalDAV: CTag совпал XML с диска; изменился/пусто null.
*/
class CalendarEventsCacheTest {
@get:Rule
val tmp = TemporaryFolder()
private fun cache() = CalendarEventsCache(tmp.newFolder("caldav"))
@Test fun hit_when_ctag_matches() {
val c = cache()
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml>events</xml>")
assertEquals("<xml>events</xml>", c.get("acc", "/cal/personal/", "r1", "ctag-1"))
}
@Test fun miss_when_ctag_changed() {
val c = cache()
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml/>")
assertNull(c.get("acc", "/cal/personal/", "r1", "ctag-2"))
}
@Test fun miss_on_blank_ctag_and_unknown_range() {
val c = cache()
c.put("acc", "/cal/personal/", "r1", "", "<xml/>") // пустой ctag не кэшируется
assertNull(c.get("acc", "/cal/personal/", "r1", ""))
assertNull(c.get("acc", "/cal/personal/", "other-range", "ctag-1"))
}
@Test fun entries_are_isolated_by_account_calendar_range() {
val c = cache()
c.put("acc1", "/cal/a/", "r1", "t", "<a1/>")
c.put("acc2", "/cal/a/", "r1", "t", "<a2/>")
c.put("acc1", "/cal/b/", "r1", "t", "<b1/>")
assertEquals("<a1/>", c.get("acc1", "/cal/a/", "r1", "t"))
assertEquals("<a2/>", c.get("acc2", "/cal/a/", "r1", "t"))
assertEquals("<b1/>", c.get("acc1", "/cal/b/", "r1", "t"))
}
@Test fun multiline_xml_preserved() {
val c = cache()
val xml = "<multistatus>\n <response>\n <href>/e1.ics</href>\n </response>\n</multistatus>"
c.put("acc", "/cal/", "r", "ct", xml)
assertEquals(xml, c.get("acc", "/cal/", "r", "ct"))
}
}
@@ -74,6 +74,18 @@ class ContactsRepository(context: Context) {
) {
return dao.getAll(key).map { it.toItem() }
}
// Дешёвая проверка CTag: если адресные книги не менялись (и бэкфиллы сделаны) —
// полный PROPFIND всех vCard не нужен, отдаём кэш. Это ускоряет pull-to-refresh.
val needsBackfill = needsPhotoBackfill || needsDetailsBackfill
val client = authedClient(session)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val currentSig = CardDavClient.collectionSignature(client, session.serverUrl, userId)
if (!needsBackfill && currentSig != null &&
currentSig == prefs.getString(ctagKey(key), null)
) {
prefs.edit().putLong(lastSyncKey(key), System.currentTimeMillis()).apply()
return dao.getAll(key).map { it.toItem() }
}
val remote = fetchRemoteContacts(session)
val entities = remote.map { contact ->
ContactEntity(
@@ -95,11 +107,12 @@ class ContactsRepository(context: Context) {
)
}
dao.replaceAll(key, entities)
prefs.edit()
val editor = prefs.edit()
.putLong(lastSyncKey(key), System.currentTimeMillis())
.putBoolean(photoSyncDoneKey(key), true)
.putBoolean(detailsSyncDoneKey(key), true)
.apply()
if (currentSig != null) editor.putString(ctagKey(key), currentSig) else editor.remove(ctagKey(key))
editor.apply()
return entities.map { it.toItem() }
}
@@ -190,6 +203,8 @@ class ContactsRepository(context: Context) {
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
private fun ctagKey(accountKey: String) = "ctag_$accountKey"
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
@@ -21,6 +21,8 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -51,6 +53,7 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen(
session: AuthSession,
@@ -76,6 +79,7 @@ fun ContactsScreen(
modifier = modifier,
loading = state.loading && state.contacts.isEmpty(),
error = state.error,
onErrorRetry = { vm.refresh(session, force = true) },
) {
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
// панели, теперь — из шапки, панель стала навигационной)
@@ -94,14 +98,13 @@ fun ContactsScreen(
)
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
}
if (state.syncing && state.contacts.isNotEmpty()) {
Text(
"Обновление…",
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
)
}
PullToRefreshBox(
isRefreshing = state.syncing && state.contacts.isNotEmpty(),
onRefresh = { vm.refresh(session, force = true) },
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
ContactListRow(
@@ -112,6 +115,7 @@ fun ContactsScreen(
}
}
}
}
state.selectedContact?.let { contact ->
ContactDetailSheet(
@@ -277,17 +281,19 @@ fun ContactListRow(
}
}
Column(modifier = Modifier.weight(1f)) {
// Живая тема: имя 16/500, детали 14/500 серым
Text(
contact.displayName.ifBlank { contact.email },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (contact.email.isNotBlank() && contact.displayName.isNotBlank()) {
Text(
contact.email,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -295,7 +301,7 @@ fun ContactListRow(
} else if (contact.phone.isNotBlank()) {
Text(
contact.phone,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -99,14 +99,16 @@ class ContactsViewModel(
}
}
fun refresh(session: AuthSession, showLoading: Boolean = false) {
fun refresh(session: AuthSession, showLoading: Boolean = false, force: Boolean = false) {
viewModelScope.launch(Dispatchers.IO) {
if (showLoading && _state.value.contacts.isEmpty()) {
_state.update { it.copy(loading = true, error = null) }
} else {
_state.update { it.copy(syncing = true, error = null) }
}
runCatching { repository.syncContacts(session) }
// force (pull-to-refresh) с CTag-проверкой дешёвый: 1 лёгкий PROPFIND,
// полная закачка vCard — только если адресные книги реально менялись
runCatching { repository.syncContacts(session, force = force) }
.onFailure { t ->
_state.update {
it.copy(
@@ -0,0 +1,260 @@
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.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DeckCardSheet(
card: DeckCard,
boardLabels: List<DeckLabel>,
stacks: List<DeckStack>,
canEdit: Boolean,
busy: Boolean,
onDismiss: () -> Unit,
onToggleDone: () -> Unit,
onSave: (title: String, description: String, duedate: String?) -> Unit,
onToggleLabel: (DeckLabel) -> 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 (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)
}
}
}
}
// Переместить в колонку
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,
) {
var text by remember { mutableStateOf("") }
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),
)
}
}
}
}
}
/** Парсит 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,228 @@
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"),
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 },
)
}
}
if (stackId > 0) {
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
}
}
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")),
)
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
}
// --- Запись ---
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)
}
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())
}
// --- 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 +231,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 +252,19 @@ 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(),
)
data class DeckBoardDetail(
val boardId: Int,
val title: String,
val canEdit: Boolean,
val labels: List<DeckLabel>,
val stacks: List<DeckStack>,
)
@@ -1,22 +1,55 @@
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.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
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, ExperimentalLayoutApi::class)
@Composable
fun DeckScreen(
session: AuthSession,
@@ -27,23 +60,18 @@ 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) }
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,
@@ -51,32 +79,256 @@ 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() })
}
},
) {
val detail = state.boardDetail
PullToRefreshBox(
isRefreshing = state.loading && (state.boards.isNotEmpty() || detail != null),
onRefresh = {
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
},
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 },
)
}
}
} 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,
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) },
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 },
)
}
}
@Composable
private 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
private fun DeckStackSection(
stack: DeckStack,
canEdit: Boolean,
onCardClick: (DeckCard) -> Unit,
onToggleDone: (DeckCard) -> Unit,
onAddCard: () -> Unit,
) {
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))
}
}
}
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) }
if (due != null || card.assignees.isNotEmpty()) {
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 (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,
)
}
}
/** Цвет 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,116 @@ 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)
// если открыта карточка — подставим её свежую версию
val openedId = _state.value.openedCard?.id
val freshCard = openedId?.let { id ->
detail.stacks.flatMap { it.cards }.firstOrNull { it.id == id }
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
_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)
.onSuccess { card -> openBoard(session, card.boardId) }
.onFailure { t -> fail(t, "Не удалось открыть карточку") }
}
.onFailure { t ->
_state.value = _state.value.copy(
}
// --- Действия (оптимистично не мутируем, перечитываем доску) ---
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 createStack(session: AuthSession, title: String) {
val boardId = _state.value.selectedBoardId ?: return
mutate(session) { repository.createStack(session, boardId, title.trim()) }
}
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,
error = t.message ?: "Не удалось открыть карточку",
busy = false,
error = t.message ?: fallback,
unauthorized = t is UnauthorizedException,
)
}
}
}
}
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_3969_9720)">
<g clip-path="url(#clip1_3969_9720)">
<rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25" stroke="#808080" stroke-width="1.5"/>
</g>
</g>
<defs>
<clipPath id="clip0_3969_9720">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_3969_9720">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 592 B

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_3969_9737)">
<g clip-path="url(#clip1_3969_9737)">
<rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25" fill="#70B62B" stroke="#70B62B" stroke-width="1.5"/>
<path d="M4 9.14286L7.80954 11.864C7.90955 11.9354 8.04971 11.9006 8.10467 11.7907L12 4" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_3969_9737">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_3969_9737">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 768 B

@@ -0,0 +1,13 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_655_2917)">
<path d="M2.5 5C2.5 3.61929 3.61929 2.5 5 2.5H10H15C16.3807 2.5 17.5 3.61929 17.5 5V12.2727C17.5 13.6534 16.3807 14.7727 15 14.7727H11.6654C11.3957 14.7727 11.1333 14.86 10.9172 15.0214L6.32676 18.4514C6.16189 18.5746 5.92712 18.4569 5.92712 18.2511V15.0227C5.92712 14.8847 5.81519 14.7727 5.67712 14.7727H5C3.61929 14.7727 2.5 13.6534 2.5 12.2727V5Z" stroke="black" stroke-width="1.5"/>
<path d="M6.25 8.75H6.25972" stroke="black" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0098 8.75H10.0195" stroke="black" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.7695 8.75H13.7793" stroke="black" stroke-width="1.875" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_655_2917">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 983 B

@@ -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_10995_59807)">
<circle cx="8.5" cy="13.5" r="1.5" transform="rotate(-90 8.5 13.5)" fill="#808080"/>
<circle cx="8.5" cy="8.5" r="1.5" transform="rotate(-90 8.5 8.5)" fill="#808080"/>
<circle cx="8.5" cy="3.5" r="1.5" transform="rotate(-90 8.5 3.5)" fill="#808080"/>
</g>
<defs>
<clipPath id="clip0_10995_59807">
<rect width="16" height="16" fill="white" transform="matrix(0 -1 1 0 0 16)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 538 B

@@ -0,0 +1,17 @@
<svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_17_4782)">
<circle cx="37.5" cy="37.5" r="37.5" fill="#E6E6E6"/>
<path d="M61.875 32.8754V45.3395C61.875 48.1108 59.6284 50.3574 56.857 50.3574H16.268C13.4966 50.3574 11.25 48.1108 11.25 45.3395V27.2504C11.25 24.479 13.4966 22.2324 16.268 22.2324H37.2965C38.6273 22.2324 39.9037 22.7611 40.8447 23.7021L43.5303 26.3877C44.4713 27.3287 45.7477 27.8574 47.0785 27.8574H56.857C59.6284 27.8574 61.875 30.104 61.875 32.8754Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
<path d="M18.75 10.9824V50.3574H56.25V10.9824H18.75Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
<path d="M16.875 14.7324V55.9824H52.5V14.7324H16.875Z" fill="#FCFEFC" stroke="black" stroke-width="1.67266"/>
<path d="M11.25 42.2504V58.4645C11.25 61.2358 13.4966 63.4824 16.268 63.4824H58.732C61.5034 63.4824 63.75 61.2358 63.75 58.4645V36.6254C63.75 33.854 61.5034 31.6074 58.732 31.6074H33.3359C32.0202 31.6074 30.7571 32.1242 29.8186 33.0464L27.0235 35.7934C26.085 36.7157 24.8219 37.2324 23.5062 37.2324H16.268C13.4966 37.2324 11.25 39.479 11.25 42.2504Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
<path d="M20.625 20.3574H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
<path d="M20.625 24.1074H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
<path d="M20.625 27.8574H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
</g>
<defs>
<clipPath id="clip0_17_4782">
<rect width="75" height="75" rx="15" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 4.72274V12.6619C15 13.4009 14.4009 14 13.6619 14H2.33813C1.5991 14 1 13.4009 1 12.6619L1 3.33812C1 2.5991 1.5991 2 2.33813 2H8.17409C8.55792 2 8.92327 2.16483 9.17728 2.45259L9.60049 2.93203C9.8545 3.21979 10.2199 3.38462 10.6037 3.38462H13.6619C14.4009 3.38462 15 3.98371 15 4.72274Z" stroke="#ffffffff" stroke-width="1.5"/>
<path d="M1 8.74989V12.6619C1 13.4009 1.5991 14 2.33813 14H13.6619C14.4009 14 15 13.4009 15 12.6619V7.33812C15 6.5991 14.4009 6 13.6619 6H6.86607C6.52921 6 6.20475 6.12705 5.95745 6.35578L5.20044 7.05598C4.95315 7.28472 4.62868 7.41176 4.29183 7.41176H2.33812C1.5991 7.41176 1 8.01086 1 8.74989Z" stroke="#ffffffff" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 779 B

@@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_17_4585)">
<path d="M4.25 2L4.25 6C4.25 6.13807 4.13807 6.25 4 6.25L2 6.25C1.86193 6.25 1.75 6.13807 1.75 6L1.75 2C1.75 1.86193 1.86193 1.75 2 1.75L4 1.75C4.13807 1.75 4.25 1.86193 4.25 2Z" stroke="#808080" stroke-width="1.5"/>
<path d="M4.25 10L4.25 14C4.25 14.1381 4.13807 14.25 4 14.25H2C1.86193 14.25 1.75 14.1381 1.75 14L1.75 10C1.75 9.86193 1.86193 9.75 2 9.75H4C4.13807 9.75 4.25 9.86193 4.25 10Z" stroke="#808080" stroke-width="1.5"/>
<path d="M9.25 2V6C9.25 6.13807 9.13807 6.25 9 6.25L7 6.25C6.86193 6.25 6.75 6.13807 6.75 6L6.75 2C6.75 1.86193 6.86193 1.75 7 1.75L9 1.75C9.13807 1.75 9.25 1.86193 9.25 2Z" stroke="#808080" stroke-width="1.5"/>
<path d="M9.25 10V14C9.25 14.1381 9.13807 14.25 9 14.25H7C6.86193 14.25 6.75 14.1381 6.75 14L6.75 10C6.75 9.86193 6.86193 9.75 7 9.75H9C9.13807 9.75 9.25 9.86193 9.25 10Z" stroke="#808080" stroke-width="1.5"/>
<path d="M14.25 2V6C14.25 6.13807 14.1381 6.25 14 6.25L12 6.25C11.8619 6.25 11.75 6.13807 11.75 6V2C11.75 1.86193 11.8619 1.75 12 1.75L14 1.75C14.1381 1.75 14.25 1.86193 14.25 2Z" stroke="#808080" stroke-width="1.5"/>
<path d="M14.25 10V14C14.25 14.1381 14.1381 14.25 14 14.25H12C11.8619 14.25 11.75 14.1381 11.75 14V10C11.75 9.86193 11.8619 9.75 12 9.75H14C14.1381 9.75 14.25 9.86193 14.25 10Z" stroke="#808080" stroke-width="1.5"/>
</g>
<defs>
<clipPath id="clip0_17_4585">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,21 @@
<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_24542)">
<g clip-path="url(#clip1_1460_24542)">
<g clip-path="url(#clip2_1460_24542)">
<path d="M10.2102 14.9989V9.10405C10.2102 8.90862 10.1326 8.7212 9.99441 8.58301C9.85623 8.44482 9.6688 8.36719 9.47337 8.36719H6.52592C6.3305 8.36719 6.14307 8.44482 6.00488 8.58301C5.8667 8.7212 5.78906 8.90862 5.78906 9.10405V14.9989" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.36719 6.89451C1.36714 6.68014 1.41386 6.46833 1.50409 6.27387C1.59432 6.07941 1.72589 5.90697 1.88962 5.76859L7.04766 1.34815C7.31366 1.12334 7.65068 1 7.99895 1C8.34722 1 8.68424 1.12334 8.95024 1.34815L14.1083 5.76859C14.272 5.90697 14.4036 6.07941 14.4938 6.27387C14.584 6.46833 14.6308 6.68014 14.6307 6.89451V13.5263C14.6307 13.9171 14.4754 14.292 14.1991 14.5684C13.9227 14.8447 13.5478 15 13.157 15H2.84091C2.45006 15 2.07521 14.8447 1.79883 14.5684C1.52245 14.292 1.36719 13.9171 1.36719 13.5263V6.89451Z" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</g>
<defs>
<clipPath id="clip0_1460_24542">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_1460_24542">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip2_1460_24542">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5015_21462)">
<rect x="1" y="2" width="3" height="3" fill="#151515"/>
<path d="M6 3.5L14 3.5" stroke="#151515" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="1" y="7" width="3" height="3" fill="#151515"/>
<path d="M6 8.5L14 8.5" stroke="#151515" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="1" y="12" width="3" height="3" fill="#151515"/>
<path d="M6 13.5L14 13.5" stroke="#151515" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_5015_21462">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 749 B

@@ -0,0 +1,13 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_655_7627)">
<path d="M15.3624 8.67973C15.2352 10.4449 13.9258 11.8048 12.4978 11.8048C11.0698 11.8048 9.75817 10.4454 9.63317 8.67973C9.50296 6.84333 10.7777 5.55469 12.4978 5.55469C14.2179 5.55469 15.4926 6.87675 15.3624 8.67973Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.5004 14.584C9.67175 14.584 6.95162 15.989 6.27017 18.7251C6.17989 19.0871 6.50327 19.3756 6.87524 19.3756H18.3187C18.6907 19.3756 18.82 19.0871 18.731 18.7251C18.0496 15.9451 15.3295 14.584 12.5004 14.584Z" stroke="black" stroke-width="1.5" stroke-miterlimit="10"/>
<path d="M8.74367 3.83406C8.63931 5.28263 7.55198 6.42347 6.37993 6.42347C5.20787 6.42347 4.11876 5.28308 4.01618 3.83406C3.90959 2.32706 4.96792 1.25 6.37993 1.25C7.79193 1.25 8.85026 2.35471 8.74367 3.83406Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.75 9.39002C7.97203 8.92775 7.11518 8.75 6.20704 8.75C3.96579 8.75 1.80642 10.1949 1.26551 13.0094C1.19439 13.3816 1.37455 13.75 1.66936 13.75H6.50875" stroke="black" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_655_7627">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_245_63266)">
<path d="M1.5 8L14.5 8" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
<path d="M8 1.5L8 14.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_245_63266">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 415 B

@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0)"><circle cx="5.5" cy="5.5" r="4.75" stroke="#808080" stroke-width="1.5"/><path d="M8.73541 9L14.7354 15" stroke="#808080" stroke-width="1.5" stroke-linecap="round"/></g><defs><clipPath id="clip0"><rect width="16" height="16" fill="white"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12335_36924)">
<path d="M6.10476 3.60501C6.94943 2.09034 7.37143 1.33301 8.00276 1.33301C8.63409 1.33301 9.05609 2.09034 9.90076 3.60501L10.1194 3.99701C10.3594 4.42767 10.4794 4.64301 10.6661 4.78501C10.8528 4.92701 11.0861 4.97967 11.5528 5.08501L11.9768 5.18101C13.6168 5.55234 14.4361 5.73767 14.6314 6.36501C14.8261 6.99167 14.2674 7.64567 13.1494 8.95301L12.8601 9.29101C12.5428 9.66234 12.3834 9.84834 12.3121 10.0777C12.2408 10.3077 12.2648 10.5557 12.3128 11.051L12.3568 11.5023C12.5254 13.247 12.6101 14.119 12.0994 14.5063C11.5888 14.8937 10.8208 14.5403 9.28609 13.8337L8.88809 13.651C8.45209 13.4497 8.23409 13.3497 8.00276 13.3497C7.77143 13.3497 7.55343 13.4497 7.11743 13.651L6.72009 13.8337C5.18476 14.5403 4.41676 14.8937 3.90676 14.507C3.39543 14.119 3.48009 13.247 3.64876 11.5023L3.69276 11.0517C3.74076 10.5557 3.76476 10.3077 3.69276 10.0783C3.62209 9.84834 3.46276 9.66234 3.14543 9.29167L2.85609 8.95301C1.73809 7.64634 1.17943 6.99234 1.37409 6.36501C1.56876 5.73767 2.38943 5.55167 4.02943 5.18101L4.45343 5.08501C4.91943 4.97967 5.15209 4.92701 5.33943 4.78501C5.52676 4.64301 5.64609 4.42767 5.88609 3.99701L6.10476 3.60501Z" stroke="#808080" stroke-width="1.5"/>
</g>
<defs>
<clipPath id="clip0_12335_36924">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="#70B62B" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_11908_43900)">
<path d="M6.10476 3.60501C6.94943 2.09034 7.37143 1.33301 8.00276 1.33301C8.63409 1.33301 9.05609 2.09034 9.90076 3.60501L10.1194 3.99701C10.3594 4.42767 10.4794 4.64301 10.6661 4.78501C10.8528 4.92701 11.0861 4.97967 11.5528 5.08501L11.9768 5.18101C13.6168 5.55234 14.4361 5.73767 14.6314 6.36501C14.8261 6.99167 14.2674 7.64567 13.1494 8.95301L12.8601 9.29101C12.5428 9.66234 12.3834 9.84834 12.3121 10.0777C12.2408 10.3077 12.2648 10.5557 12.3128 11.051L12.3568 11.5023C12.5254 13.247 12.6101 14.119 12.0994 14.5063C11.5888 14.8937 10.8208 14.5403 9.28609 13.8337L8.88809 13.651C8.45209 13.4497 8.23409 13.3497 8.00276 13.3497C7.77143 13.3497 7.55343 13.4497 7.11743 13.651L6.72009 13.8337C5.18476 14.5403 4.41676 14.8937 3.90676 14.507C3.39543 14.119 3.48009 13.247 3.64876 11.5023L3.69276 11.0517C3.74076 10.5557 3.76476 10.3077 3.69276 10.0783C3.62209 9.84834 3.46276 9.66234 3.14543 9.29167L2.85609 8.95301C1.73809 7.64634 1.17943 6.99234 1.37409 6.36501C1.56876 5.73767 2.38943 5.55167 4.02943 5.18101L4.45343 5.08501C4.91943 4.97967 5.15209 4.92701 5.33943 4.78501C5.52676 4.64301 5.64609 4.42767 5.88609 3.99701L6.10476 3.60501Z" stroke="#70B62B" stroke-width="1.5"/>
</g>
<defs>
<clipPath id="clip0_11908_43900">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,13 +1,16 @@
package ru.forbion.f7cloud.feature.files
object FileIcons {
/** Локальные SVG хрома Файлов из боевой темы forbion (assets/chrome/, coil-svg). */
fun chromeAsset(name: String): String = "file:///android_asset/chrome/$name"
/**
* Иконка типа файла. Типы файлов ЛОКАЛЬНЫЕ SVG из дизайна (assets/filetypes/, грузятся
* coil-svg): офлайн + точные иконки макета (раньше тянулись с сервера аудит: офлайн ломался).
* Папка пока с сервера (иконки папки в наборе дизайна нет; TODO забрать из Figma).
* Папка фирменная folder-icon-colored.svg из боевой темы (тоже локально).
*/
fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String {
if (isDirectory) return "${serverUrl.trimEnd('/')}/core/img/filetypes/folder.svg"
if (isDirectory) return chromeAsset("folder-icon-colored.svg")
return "file:///android_asset/filetypes/${localIcon(name)}.svg"
}
@@ -4,34 +4,32 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -40,30 +38,59 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Radius
import ru.forbion.f7cloud.core.designsystem.F7Spacing
// Размеры — из боевой темы forbion (_mobile-files.css), копия один в один.
enum class FilesViewMode { List, Grid }
/** Квадратная кнопка-переключатель вида список⇄сетка справа от поиска (макет F-17/F-22). */
/**
* Чекбокс из боевой темы: 16×16, серый контур / зелёная заливка с галкой
* (checkbox-blank-outline-icon.svg / checkbox-marked-icon.svg). Тап-зона задаётся снаружи.
*/
@Composable
fun F7ThemeCheckbox(checked: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) {
AsyncImage(
model = FileIcons.chromeAsset(
if (checked) "checkbox-marked-icon.svg" else "checkbox-blank-outline-icon.svg",
),
contentDescription = if (checked) "Снять выбор" else "Выбрать",
modifier = modifier
.size(16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onToggle,
),
)
}
/** Стиль «пилюли» темы: фон #FBFBFB, рамка #E6E6E6, мягкая тень 0 1px 2px. */
private fun Modifier.f7PillSurface(shape: RoundedCornerShape): Modifier = this
.shadow(1.dp, shape, ambientColor = F7Colors.Border, spotColor = F7Colors.Border)
.clip(shape)
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, shape)
/** Круглая кнопка-переключатель список⇄сетка справа от поиска (40×40, иконка 16). */
@Composable
fun FilesViewToggleButton(viewMode: FilesViewMode, onToggle: () -> Unit) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(F7Radius.md))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.md))
.f7PillSurface(RoundedCornerShape(F7Radius.pill))
.clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (viewMode == FilesViewMode.List) Icons.Filled.GridView else Icons.AutoMirrored.Filled.List,
AsyncImage(
model = FileIcons.chromeAsset(
if (viewMode == FilesViewMode.List) "grid-files-gray.svg" else "list-view-icon.svg",
),
contentDescription = "Сменить вид",
tint = F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(16.dp),
)
}
}
/** Хлебные крошки: домой + сегменты пути через ›, прокрутка по горизонтали (макет F-21/F-22). */
/** Хлебные крошки: дом 16 + сегменты (все — серые 14/20) через шеврон 16, h-прокрутка. */
@Composable
fun FilesBreadcrumb(
segments: List<String>,
@@ -75,23 +102,28 @@ fun FilesBreadcrumb(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.sm),
.padding(horizontal = F7Spacing.sm, vertical = F7Spacing.xs),
horizontalArrangement = Arrangement.spacedBy(F7Spacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Home,
AsyncImage(
model = FileIcons.chromeAsset("home-bottom.svg"),
contentDescription = "В начало",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(22.dp)
.size(16.dp)
.clickable(onClick = onHomeClick),
)
segments.forEachIndexed { i, seg ->
Text(" ", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = F7Colors.TextSecondary,
modifier = Modifier.size(16.dp),
)
Text(
text = seg,
style = MaterialTheme.typography.bodyMedium,
color = if (i == segments.lastIndex) F7Colors.TextPrimary else F7Colors.TextSecondary,
color = F7Colors.TextSecondary,
maxLines = 1,
modifier = Modifier.clickable { onSegmentClick(i) },
)
@@ -99,7 +131,7 @@ fun FilesBreadcrumb(
}
}
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (макет F-18/F-22). */
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (пилюли 25dp, minWidth 86). */
@Composable
fun FilesFilterChips(
onTypeClick: () -> Unit,
@@ -111,7 +143,7 @@ fun FilesFilterChips(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.xs),
.padding(horizontal = F7Spacing.xs, vertical = F7Spacing.xs),
horizontalArrangement = Arrangement.spacedBy(F7Spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -125,16 +157,21 @@ fun FilesFilterChips(
private fun FilterChip(label: String, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(F7Radius.pill))
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.pill))
.height(25.dp)
.widthIn(min = 86.dp)
.f7PillSurface(RoundedCornerShape(F7Radius.pill))
.clickable(onClick = onClick)
.padding(horizontal = F7Spacing.md, vertical = F7Spacing.xs),
.padding(horizontal = F7Spacing.sm),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
/** Карточка сетки: чекбокс + звезда сверху, крупная иконка по центру, имя + ⋮ снизу (макет F-17). */
/**
* Карточка сетки (2 колонки): чекбокс 16 слева-сверху, зелёная звезда 16 справа-сверху
* (только у избранных), иконка 80 по центру, имя 14/20 + 16 снизу. Радиус 12, отступ 12.
*/
@Composable
fun FilesGridCard(
serverUrl: String,
@@ -146,41 +183,40 @@ fun FilesGridCard(
onMenuClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(12.dp)
Column(
modifier = modifier
.clip(RoundedCornerShape(F7Radius.lg))
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.lg))
.clip(shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Background)
.border(1.dp, F7Colors.Border, shape)
.clickable(onClick = onOpenClick)
.padding(F7Spacing.md),
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = selected,
onCheckedChange = { onSelectedChange() },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Spacer(Modifier.width(F7Spacing.sm))
F7ThemeCheckbox(checked = selected, onToggle = onSelectedChange)
Box(modifier = Modifier.weight(1f))
Icon(
imageVector = if (favorite) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = null,
tint = if (favorite) F7Colors.Primary else F7Colors.TextMuted,
modifier = Modifier.size(18.dp),
if (favorite) {
AsyncImage(
model = FileIcons.chromeAsset("star-green-full.svg"),
contentDescription = "В избранном",
modifier = Modifier.size(16.dp),
)
}
}
Spacer(Modifier.height(F7Spacing.sm))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = F7Spacing.lg),
.height(80.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null,
modifier = Modifier.size(48.dp),
modifier = Modifier.size(80.dp),
)
}
Spacer(Modifier.height(F7Spacing.sm))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = item.name,
@@ -191,12 +227,11 @@ fun FilesGridCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Filled.MoreVert,
AsyncImage(
model = FileIcons.chromeAsset("dots-vertic-gray.svg"),
contentDescription = "Меню",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(18.dp)
.size(16.dp)
.clickable(onClick = onMenuClick),
)
}
@@ -221,7 +256,7 @@ fun FilesEmptyState(
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(F7Radius.pill))
.clip(CircleShape)
.background(F7Colors.SurfaceMuted),
contentAlignment = Alignment.Center,
) {
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxHeight
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -26,11 +28,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.CheckboxDefaults
@@ -66,6 +64,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
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.IntOffset
import androidx.compose.ui.unit.dp
@@ -103,6 +102,7 @@ fun FilesNavigationSidebar(
onToggleTreePath: (String) -> Unit,
onToggleSection: (String) -> Unit,
onWebOnlyClick: (String) -> Unit,
onCreateClick: (() -> Unit)? = null,
) {
val base = serverUrl.trimEnd('/')
AnimatedVisibility(
@@ -124,11 +124,18 @@ fun FilesNavigationSidebar(
Column(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.86f)
.background(F7Colors.Surface)
.fillMaxWidth(0.9f)
.background(F7Colors.Background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 12.dp),
.padding(start = 10.dp, top = 10.dp, end = 16.dp, bottom = 26.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
onCreateClick?.let { create ->
FilesSidebarCreateButton(
onClick = create,
modifier = Modifier.padding(bottom = 4.dp),
)
}
FilesSidebarRow(
serverUrl = base,
iconPath = "files/folder-gray.svg",
@@ -231,11 +238,13 @@ fun FilesNavigationSidebar(
}
if (usage.isNotBlank()) {
Spacer(Modifier.height(8.dp))
// Живая тема: квота по центру, серым 16/20, без прогресс-бара.
Text(
text = "Использовано $usage",
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyLarge,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(horizontal = 8.dp),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -305,6 +314,56 @@ private fun FilesTreeNodeRows(
}
}
/**
* Градиентная кнопка «Создать или загрузить» сверху панели (живая тема: 40dp, r100,
* градиент 104°, белая папка слева, разделитель и плюс справа).
*/
@Composable
fun FilesSidebarCreateButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(F7Radius.pill))
.background(
Brush.linearGradient(listOf(Color(0xFFC0FF7B), Color(0xFF7CBC3D))),
)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = FileIcons.chromeAsset("folder-white.svg"),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(
text = "Создать или загрузить",
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = Color(0xFFFDFDFD),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Box(
modifier = Modifier
.width(2.dp)
.height(40.dp)
.background(Color.White),
)
AsyncImage(
model = FileIcons.chromeAsset("plus-white.svg"),
contentDescription = "Создать",
modifier = Modifier
.padding(start = 14.dp)
.size(16.dp),
)
}
}
@Composable
private fun FilesSidebarRow(
serverUrl: String,
@@ -313,36 +372,34 @@ private fun FilesSidebarRow(
selected: Boolean = false,
onClick: () -> Unit,
) {
// Живая тема: строка minHeight 40, r8, иконка 20 серым, текст 16/20 #151515,
// активная — фон #ECF9DE; правых «⋮» в навигации нет.
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) Color(0xFFE8F5E0) else Color.Transparent)
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 10.dp),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = "$serverUrl/themes/forbion/images/$iconPath",
contentDescription = null,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(8.dp))
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AsyncImage(
model = "$serverUrl/themes/forbion/images/files/dots-vertic-gray.svg",
contentDescription = null,
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
)
}
}
@@ -364,16 +421,12 @@ private fun FilesSidebarChildRow(
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
AsyncImage(
model = "$serverUrl/themes/forbion/images/files/dots-vertic-gray.svg",
contentDescription = null,
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
)
}
}
@@ -390,22 +443,23 @@ private fun FilesSidebarExpandableSection(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
.padding(horizontal = 8.dp, vertical = 10.dp),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = "$serverUrl/themes/forbion/images/$iconPath",
contentDescription = null,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(8.dp))
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(text = if (expanded) "" else "", color = F7Colors.TextSecondary)
@@ -559,7 +613,6 @@ fun FilesSearchBar(
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val base = serverUrl.trimEnd('/')
Box(
modifier = modifier
.height(40.dp)
@@ -569,7 +622,7 @@ fun FilesSearchBar(
spotColor = Color(0xFFCBCBCB),
)
.clip(RoundedCornerShape(100.dp))
.background(Color(0xFFFDFDFD))
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp)),
contentAlignment = Alignment.CenterStart,
) {
@@ -581,7 +634,7 @@ fun FilesSearchBar(
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = "$base/themes/forbion/images/header/search-gray.svg",
model = FileIcons.chromeAsset("search-gray.svg"),
contentDescription = null,
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
@@ -620,6 +673,16 @@ fun FilesSearchBar(
}
},
)
if (query.isNotEmpty()) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Очистить поиск",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(20.dp)
.clickable { onQueryChange("") },
)
}
}
}
}
@@ -733,47 +796,60 @@ private fun FilesHeaderColumn(
}
}
/**
* Панель выделения по живой теме: 40dp, чекбокс 16 «выбрать все», «Выбрать все» чёрным +
* «(Выбран N)» серым, справа пилюля «Действия» 25dp (без иконки).
*/
@Composable
fun FilesBulkActionsBar(
serverUrl: String,
selectedCount: Int,
allSelected: Boolean = false,
onToggleSelectAll: (() -> Unit)? = null,
onActionsClick: (Rect) -> Unit,
) {
var anchorBounds by remember { mutableStateOf<Rect?>(null) }
val base = serverUrl.trimEnd('/')
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
.height(40.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
F7ThemeCheckbox(checked = allSelected, onToggle = { onToggleSelectAll?.invoke() })
Text(
text = "Выбрано $selectedCount",
text = buildAnnotatedString {
append("Выбрать все ")
withStyle(SpanStyle(color = F7Colors.TextSecondary)) {
append("(Выбран $selectedCount)")
}
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
Box(
modifier = Modifier
.height(25.dp)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
.clickable {
anchorBounds?.let(onActionsClick)
}
.clickable { anchorBounds?.let(onActionsClick) }
.onGloballyPositioned { coords ->
anchorBounds = coords.boundsInWindow()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/files/dots-vertic-gray.svg",
contentDescription = null,
modifier = Modifier.size(14.dp),
contentScale = ContentScale.Fit,
Text(
"Действия",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
Text("Действия", style = MaterialTheme.typography.bodyMedium)
}
}
}
@@ -793,27 +869,36 @@ fun FilesListRow(
val hasExt = !item.isDirectory && item.name.contains('.')
val baseName = if (hasExt) item.name.substringBeforeLast('.') else item.name
val extension = if (hasExt && showExtensions) "." + item.name.substringAfterLast('.') else ""
// Строка 44dp по боевой теме (_mobile-files.css): чекбокс 16 в колонке 44, иконка 30
// (зелёная звезда 16 поверх у избранных), имя, размер 56 по центру, дата 88 вправо, ⋮ в 32.
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(F7Radius.md))
// выбранная строка — светло-зелёный фон (макет F-21)
.height(44.dp)
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable(onClick = onOpenClick)
.padding(horizontal = F7Spacing.sm, vertical = 10.dp),
.clickable(onClick = onOpenClick),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = selected,
onCheckedChange = { onSelectedChange() },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Box(modifier = Modifier.size(44.dp), contentAlignment = Alignment.Center) {
F7ThemeCheckbox(checked = selected, onToggle = onSelectedChange)
}
Box(modifier = Modifier.size(30.dp)) {
AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null,
modifier = Modifier.size(24.dp),
modifier = Modifier.size(30.dp),
contentScale = ContentScale.Fit,
)
if (item.favorite) {
AsyncImage(
model = FileIcons.chromeAsset("star-green-full.svg"),
contentDescription = "В избранном",
modifier = Modifier
.size(16.dp)
.offset(x = 20.dp, y = (-4).dp),
)
}
}
Text(
text = buildAnnotatedString {
append(baseName)
@@ -823,17 +908,38 @@ fun FilesListRow(
},
modifier = Modifier
.weight(1f)
.padding(horizontal = F7Spacing.sm),
.padding(start = F7Spacing.sm, end = F7Spacing.xs),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
item.size?.takeIf { !item.isDirectory }?.let { size ->
Text(
text = formatFileSize(size),
modifier = Modifier.width(56.dp),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
textAlign = TextAlign.Center,
)
}
item.lastModified?.let { mtime ->
Text(
text = formatRelativeTime(mtime),
modifier = Modifier.width(88.dp),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.width(32.dp)
.height(44.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -845,7 +951,7 @@ fun FilesListRow(
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "${serverUrl.trimEnd('/')}/themes/forbion/images/files/dots-vertic-gray.svg",
model = FileIcons.chromeAsset("dots-vertic-gray.svg"),
contentDescription = "Действия",
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
@@ -854,6 +960,15 @@ fun FilesListRow(
}
}
/** Относительное время как в вебе (moment.js): «5 мин. назад», «вчера», иначе дата. */
internal fun formatRelativeTime(epochMillis: Long): String =
android.text.format.DateUtils.getRelativeTimeSpanString(
epochMillis,
System.currentTimeMillis(),
android.text.format.DateUtils.MINUTE_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
@Composable
fun FileActionMenuPopup(
expanded: Boolean,
@@ -879,12 +994,14 @@ fun FileActionMenuPopup(
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier.widthIn(min = 260.dp, max = 320.dp),
shape = RoundedCornerShape(12.dp),
shadowElevation = 8.dp,
modifier = Modifier
.widthIn(min = 280.dp, max = 340.dp)
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)),
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp,
color = Color.White,
) {
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Column(modifier = Modifier.padding(4.dp)) {
entries.forEachIndexed { index, entry ->
if (entry.destructive && index > 0) {
HorizontalDivider(
@@ -895,18 +1012,20 @@ fun FileActionMenuPopup(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 25.dp)
.clip(RoundedCornerShape(6.dp))
.clickable {
onDismiss()
onAction(entry.action)
}
.padding(horizontal = 14.dp, vertical = 11.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = "$base/themes/forbion/images/${entry.iconPath}",
contentDescription = null,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
)
Text(
@@ -956,12 +1075,14 @@ fun FilesBulkActionMenuPopup(
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier.widthIn(min = 240.dp, max = 300.dp),
shape = RoundedCornerShape(12.dp),
shadowElevation = 8.dp,
modifier = Modifier
.widthIn(min = 240.dp, max = 300.dp)
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)),
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp,
color = Color.White,
) {
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Column(modifier = Modifier.padding(4.dp)) {
entries.forEachIndexed { index, (action, label, icon) ->
if (index == entries.lastIndex) {
HorizontalDivider(
@@ -1013,25 +1134,26 @@ fun FilesDetailsSheet(
) {
if (item == null) return
var commentDraft by remember(item.relativePath) { mutableStateOf("") }
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
// Живая тема: панель на весь экран, фон #FBFBFB, без скруглений.
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.92f),
shape = RoundedCornerShape(16.dp),
color = F7Colors.Surface,
modifier = Modifier.fillMaxSize(),
color = F7Colors.Background,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
// Крестик закрытия — правый верхний угол (по макету)
// Крестик 40dp — правый верхний угол
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center,
) {
@@ -1043,32 +1165,50 @@ fun FilesDetailsSheet(
)
}
}
// Заголовок 18/600 + звезда 20 справа (живая тема)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
item.name,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AsyncImage(
model = FileIcons.chromeAsset(
if (item.favorite) "star-green-full.svg" else "star-gray.svg",
),
contentDescription = "Избранное",
modifier = Modifier
.size(20.dp)
.clickable(onClick = onToggleFavorite),
)
}
Spacer(Modifier.height(8.dp))
// Чип владельца (аватар-инициал + логин) — по макету
// Чип владельца (пилюля 25dp) слева, «дата / размер» серым справа
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.height(25.dp)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.SurfaceMuted)
.padding(start = 4.dp, end = 10.dp, top = 4.dp, bottom = 4.dp),
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(20.dp)
.size(16.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight),
contentAlignment = Alignment.Center,
@@ -1082,14 +1222,13 @@ fun FilesDetailsSheet(
}
Text(
ownerLabel,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(horizontalAlignment = Alignment.End) {
Spacer(Modifier.weight(1f))
val dateSize = buildList {
item.lastModified?.let {
add(SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)))
@@ -1099,20 +1238,9 @@ fun FilesDetailsSheet(
if (dateSize.isNotEmpty()) {
Text(
dateSize.joinToString(" / "),
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.height(8.dp))
}
Icon(
if (item.favorite) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = "Избранное",
tint = if (item.favorite) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.clickable(onClick = onToggleFavorite),
)
}
}
Spacer(Modifier.height(16.dp))
@@ -1121,14 +1249,14 @@ fun FilesDetailsSheet(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
FilesDetailsTabPill(
icon = Icons.Filled.ChatBubbleOutline,
iconAsset = "comment-icon-black.svg",
contentDescription = "Обсуждение",
selected = tab == FileDetailsTab.Events,
onClick = { onTabChange(FileDetailsTab.Events) },
modifier = Modifier.weight(1f),
)
FilesDetailsTabPill(
icon = Icons.Filled.Group,
iconAsset = "people-icon-black.svg",
contentDescription = "Общий доступ",
selected = tab == FileDetailsTab.Sharing,
onClick = { onTabChange(FileDetailsTab.Sharing) },
@@ -1179,27 +1307,31 @@ fun FilesDetailsSheet(
@Composable
private fun FilesDetailsTabPill(
icon: androidx.compose.ui.graphics.vector.ImageVector,
iconAsset: String,
contentDescription: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(14.dp)
// Живая тема: пилюля 30dp r100; активная — фон #ECF9DE, рамка #70B62B 20%; иконка темы 16.
val shape = RoundedCornerShape(100.dp)
Box(
modifier = modifier
.height(40.dp)
.height(30.dp)
.clip(shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface)
.border(1.dp, if (selected) Color.Transparent else F7Colors.Border, shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Background)
.border(
1.dp,
if (selected) F7Colors.Primary.copy(alpha = 0.2f) else F7Colors.Border,
shape,
)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
AsyncImage(
model = FileIcons.chromeAsset(iconAsset),
contentDescription = contentDescription,
tint = if (selected) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(16.dp),
)
}
}
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -113,6 +114,7 @@ fun FilesScreen(
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
OfficeWarmup.warm(session)
OfficeWarmup.warmWebViewEngine(context) // прогрев WebView-движка для быстрого docx/xlsx
vm.load(session)
vm.loadSidebarData(session)
}
@@ -172,6 +174,7 @@ fun FilesScreen(
modifier = Modifier.fillMaxSize(),
loading = state.loading && displayItems.isEmpty(),
error = state.error,
onErrorRetry = { vm.refreshCurrent(session) },
headerActions = {},
) {
Column(modifier = Modifier.fillMaxSize()) {
@@ -190,9 +193,8 @@ fun FilesScreen(
FilesViewToggleButton(viewMode) {
viewMode = if (viewMode == FilesViewMode.List) FilesViewMode.Grid else FilesViewMode.List
}
// Создание (загрузка/новая папка) — раньше вызывалось из нижней панели,
// теперь кнопка в шапке (нижняя панель стала навигационной)
F7CreateButton(onClick = { showCreateMenu = true }, size = 40.dp)
// Кнопки создания в шапке нет (живая тема): «Создать или загрузить» —
// градиентная кнопка сверху панели навигации.
}
// Хлебные крошки
val crumbSegments = state.currentPath.split("/").filter { it.isNotBlank() }
@@ -202,9 +204,15 @@ fun FilesScreen(
onSegmentClick = { i -> vm.navigateToPath(session, crumbSegments.take(i + 1).joinToString("/")) },
)
if (selectionMode) {
val allSelected = displayItems.isNotEmpty() &&
state.selectedPaths.size >= displayItems.size
FilesBulkActionsBar(
serverUrl = session.serverUrl,
selectedCount = state.selectedPaths.size,
allSelected = allSelected,
onToggleSelectAll = {
if (allSelected) vm.clearSelection() else vm.selectAllVisible()
},
onActionsClick = { rect ->
bulkMenuAnchor = rect
bulkMenuOpen = true
@@ -235,7 +243,12 @@ fun FilesScreen(
) {
val openItem: (FileItem) -> Unit = { item ->
val openable = !item.isDirectory &&
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
(
OfficeFiles.isOfficeFile(item.name) ||
MarkdownFiles.isMarkdown(item.name) ||
WebOpenableFiles.isWebOpenable(item.name) ||
OpenableFiles.isOpenable(item.name)
)
when {
item.isDirectory -> vm.openFolder(session, item)
openable -> vm.openItem(session, item)
@@ -322,6 +335,10 @@ fun FilesScreen(
vm.showWebOnlyMessage(title)
onSidebarOpenChange(false)
},
onCreateClick = {
onSidebarOpenChange(false)
showCreateMenu = true
},
)
FilesCreateMenu(
@@ -462,4 +479,14 @@ fun FilesScreen(
)
}
}
// Нативный редактор markdown/текста поверх всего (быстрый, вместо тяжёлого веб-Text)
state.markdownDoc?.let { doc ->
BackHandler { vm.closeMarkdown() }
MarkdownEditorScreen(
session = session,
doc = doc,
onClose = { vm.closeMarkdown() },
)
}
}
@@ -20,6 +20,8 @@ data class FilesUiState(
val openingFile: String? = null,
val editorLaunch: OfficeEditorLaunch? = null,
val openAction: FileOpenAction? = null,
// Нативный markdown-редактор: открытый текстовый файл (путь + имя).
val markdownDoc: MarkdownDoc? = null,
val searchQuery: String = "",
val searchResults: List<FilesSearchHit> = emptyList(),
val searchActive: Boolean = false,
@@ -487,6 +487,8 @@ class FilesViewModel(
if (item.isDirectory) return
when {
OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item)
MarkdownFiles.isMarkdown(item.name) -> openMarkdown(item)
WebOpenableFiles.isWebOpenable(item.name) -> openWebFile(session, item)
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
else -> _state.value = _state.value.copy(
@@ -495,6 +497,37 @@ class FilesViewModel(
}
}
/** Markdown/текст — нативный редактор (мгновенно, WebDAV). */
private fun openMarkdown(item: FileItem) {
_state.value = _state.value.copy(
markdownDoc = MarkdownDoc(relativePath = item.relativePath, name = item.name),
)
}
fun closeMarkdown() {
_state.value = _state.value.copy(markdownDoc = null)
}
/** Markdown/текст/доска — открываем каноническую ссылку /f/<id> в авторизованном WebView. */
private fun openWebFile(session: AuthSession, item: FileItem) {
val fileId = item.fileId ?: run {
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
return
}
val base = session.serverUrl.trimEnd('/')
_state.value = _state.value.copy(
editorLaunch = OfficeEditorLaunch(
url = "$base/index.php/f/$fileId",
title = item.name,
username = session.username,
password = session.appPassword,
trustAllCerts = session.trustAllCerts,
serverUrl = session.serverUrl,
collaboraBaseUrl = "",
),
)
}
private fun openOfficeFile(session: AuthSession, item: FileItem) {
val fileId = item.fileId ?: run {
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
@@ -0,0 +1,244 @@
package ru.forbion.f7cloud.feature.files
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
/**
* Нативный редактор markdown/текста: качает содержимое по WebDAV (мгновенно), правка в
* TextField, предпросмотр с базовым рендером, сохранение по WebDAV PUT. Заменяет тяжёлый
* веб-редактор Text (который «вечно грузился», т.к. тянул весь Files UI).
*/
@Composable
fun MarkdownEditorScreen(
session: AuthSession,
doc: MarkdownDoc,
onClose: () -> Unit,
repository: MarkdownRepository = remember { MarkdownRepository() },
) {
var text by remember(doc.relativePath) { mutableStateOf("") }
var loading by remember(doc.relativePath) { mutableStateOf(true) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var preview by remember { mutableStateOf(false) }
var dirty by remember(doc.relativePath) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val isMd = doc.name.substringAfterLast('.', "").lowercase() in setOf("md", "markdown")
LaunchedEffect(doc.relativePath) {
loading = true
error = null
runCatching { withContext(Dispatchers.IO) { repository.load(session, doc.relativePath) } }
.onSuccess { text = it; loading = false }
.onFailure { error = it.message ?: "Ошибка загрузки"; loading = false }
}
Column(
modifier = Modifier
.fillMaxSize()
.background(F7Colors.Background)
.f7SafeTopInsets(),
) {
// Шапка: назад, имя, предпросмотр (для md), сохранить
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад",
tint = F7Colors.TextPrimary,
modifier = Modifier.size(24.dp).clickable(onClick = onClose),
)
Text(
doc.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
maxLines = 1,
)
if (isMd) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(if (preview) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
.clickable { preview = !preview }
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
if (preview) "Правка" else "Просмотр",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
}
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(if (dirty && !saving) F7Colors.Primary else F7Colors.SurfaceMuted)
.clickable(enabled = dirty && !saving) {
saving = true
error = null
scope.launch {
runCatching {
withContext(Dispatchers.IO) { repository.save(session, doc.relativePath, text) }
}.onSuccess { saving = false; dirty = false }
.onFailure { error = it.message ?: "Ошибка сохранения"; saving = false }
}
}
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
if (saving) "" else "Сохранить",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (dirty && !saving) androidx.compose.ui.graphics.Color.White else F7Colors.TextSecondary,
)
}
}
error?.let {
Text(it, color = F7Colors.Error, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
}
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
preview && isMd -> {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(renderMarkdown(text), style = MaterialTheme.typography.bodyLarge, color = F7Colors.TextPrimary)
}
}
else -> {
BasicTextField(
value = text,
onValueChange = { text = it; dirty = true },
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
textStyle = TextStyle(
fontSize = 15.sp,
lineHeight = 22.sp,
color = F7Colors.TextPrimary,
fontFamily = if (isMd) FontFamily.Monospace else FontFamily.Default,
),
cursorBrush = SolidColor(F7Colors.Primary),
)
}
}
}
}
/** Лёгкий рендер markdown → AnnotatedString: заголовки, жирный/курсив, списки, код, цитаты. */
private fun renderMarkdown(src: String): AnnotatedString = buildAnnotatedString {
src.lineSequence().forEachIndexed { index, raw ->
if (index > 0) append("\n")
val line = raw.trimEnd()
when {
line.startsWith("### ") -> withStyle(SpanStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold)) {
append(line.removePrefix("### "))
}
line.startsWith("## ") -> withStyle(SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold)) {
append(line.removePrefix("## "))
}
line.startsWith("# ") -> withStyle(SpanStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold)) {
append(line.removePrefix("# "))
}
line.startsWith("> ") -> withStyle(
SpanStyle(fontStyle = FontStyle.Italic, color = androidx.compose.ui.graphics.Color(0xFF808080)),
) { append(line.removePrefix("> ")) }
line.startsWith("- ") || line.startsWith("* ") -> {
append("")
appendInline(line.drop(2))
}
line.matches(Regex("^\\d+\\. .*")) -> appendInline(line)
line.startsWith("```") -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(line) }
else -> appendInline(line)
}
}
}
/** Инлайн-разметка: **жирный**, *курсив*, `код`. */
private fun androidx.compose.ui.text.AnnotatedString.Builder.appendInline(text: String) {
var i = 0
while (i < text.length) {
when {
text.startsWith("**", i) -> {
val end = text.indexOf("**", i + 2)
if (end > 0) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }
i = end + 2
} else { append(text[i]); i++ }
}
text[i] == '*' -> {
val end = text.indexOf('*', i + 1)
if (end > 0) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
i = end + 1
} else { append(text[i]); i++ }
}
text[i] == '`' -> {
val end = text.indexOf('`', i + 1)
if (end > 0) {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }
i = end + 1
} else { append(text[i]); i++ }
}
else -> { append(text[i]); i++ }
}
}
}
@@ -0,0 +1,46 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
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.NetworkFactory
import ru.forbion.f7cloud.core.network.davFileUrl
/**
* Нативная работа с текстовыми/markdown-файлами по WebDAV быстро, без тяжёлого веб-редактора
* (веб Text грузил весь Files UI). Просто GET текста и PUT при сохранении.
*/
class MarkdownRepository {
private fun client(session: AuthSession) =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
private fun url(session: AuthSession, relativePath: String): String {
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
return davFileUrl(session.serverUrl, userId, relativePath)
}
fun load(session: AuthSession, relativePath: String): String {
val request = Request.Builder().url(url(session, relativePath)).header("Accept", "*/*").get().build()
client(session).newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить файл (HTTP ${response.code})")
}
return response.body!!.string()
}
}
fun save(session: AuthSession, relativePath: String, content: String) {
val media = "text/markdown; charset=utf-8".toMediaType()
val request = Request.Builder()
.url(url(session, relativePath))
.put(content.toRequestBody(media))
.build()
client(session).newCall(request).execute().use { response ->
if (response.code !in 200..299 && response.code != 204) {
error("Не удалось сохранить (HTTP ${response.code})")
}
}
}
}
@@ -63,6 +63,23 @@ object OfficeWarmup {
}
}
@Volatile
private var engineWarmed = false
/**
* Прогрев WebView-движка: ПЕРВОЕ создание WebView в процессе тянет провайдер Chromium
* (сотни мс), из-за чего первый docx/xlsx открывается медленно. Создаём и сразу уничтожаем
* пустой WebView заранее (при входе в Файлы) движок загружается в фоне. Только UI-поток.
*/
fun warmWebViewEngine(context: android.content.Context) {
if (engineWarmed) return
engineWarmed = true
runCatching {
val wv = android.webkit.WebView(context.applicationContext)
wv.destroy()
}
}
fun clear() {
cachedServerUrl = null
cachedCollaboraUrl = null
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.files
/**
* Файлы, открываемые во ВЕБ-вьюере Nextcloud по ссылке `/index.php/f/<fileId>` сервер
* направляет в нужное приложение. Сейчас это только доска (Whiteboard, Excalidraw-канвас
* с реалтайм-бэкендом нативно не реализуется). Markdown/текст нативно, см. [MarkdownFiles].
*/
object WebOpenableFiles {
private val EXTENSIONS = setOf("whiteboard")
fun isWebOpenable(name: String): Boolean =
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
}
/** Текстовые/markdown-файлы — открываются в НАТИВНОМ редакторе (быстро, WebDAV). */
object MarkdownFiles {
private val EXTENSIONS = setOf("md", "markdown", "txt", "text", "org", "log")
fun isMarkdown(name: String): Boolean =
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
}
/** Открытый в нативном редакторе текстовый файл. */
data class MarkdownDoc(
val relativePath: String,
val name: String,
)
+4
View File
@@ -29,8 +29,12 @@ dependencies {
implementation project(':core:designsystem')
implementation project(':feature:files')
implementation project(':feature:contacts')
// создание задач/событий из письма (живой веб-паритет)
implementation project(':feature:tasks')
implementation project(':feature:calendar')
implementation libs.coroutines.android
implementation libs.json
testImplementation libs.junit
def composeBom = platform(libs.compose.bom)
implementation composeBom
implementation libs.compose.ui
@@ -0,0 +1,4 @@
<svg width="35" height="32" viewBox="0 0 35 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.33398 18.9808V25.9711C8.33398 25.9761 8.33209 25.981 8.32873 25.9845C8.32537 25.9881 8.3208 25.9901 8.31605 25.9901H2.27416C2.2718 25.9901 2.26947 25.9896 2.2673 25.9887C2.26512 25.9877 2.26314 25.9863 2.26148 25.9845C2.25812 25.981 2.25623 25.9761 2.25623 25.9711V5.02973C2.25623 5.02724 2.25669 5.02477 2.25759 5.02246C2.25849 5.02016 2.25981 5.01807 2.26148 5.0163C2.26484 5.01274 2.2694 5.01074 2.27416 5.01074H19.7382C19.7415 5.01074 19.7447 5.01166 19.7475 5.01343C19.7503 5.0152 19.7526 5.01773 19.7541 5.02076C19.7556 5.0238 19.7564 5.02722 19.7562 5.03065C19.756 5.03409 19.755 5.03742 19.7532 5.04028L16.2595 10.7647C16.2578 10.7674 16.2555 10.7696 16.2528 10.771C16.2501 10.7725 16.2471 10.7732 16.244 10.7731H8.35192C8.34716 10.7731 8.3426 10.7751 8.33924 10.7787C8.33587 10.7823 8.33398 10.7871 8.33398 10.7921V13.1672C8.33398 13.1723 8.33587 13.1771 8.33924 13.1807C8.3426 13.1842 8.34716 13.1862 8.35192 13.1862H14.9303C14.9328 13.1862 14.9352 13.1867 14.9374 13.1878C14.9397 13.1888 14.9417 13.1903 14.9434 13.1922C14.9451 13.194 14.9464 13.1963 14.9472 13.1987C14.948 13.2011 14.9484 13.2037 14.9483 13.2063L14.6937 18.9439C14.6935 18.9487 14.6915 18.9533 14.6881 18.9566C14.6848 18.96 14.6804 18.9618 14.6758 18.9618H8.35192C8.34716 18.9618 8.3426 18.9638 8.33924 18.9674C8.33587 18.9709 8.33398 18.9758 8.33398 18.9808Z" fill="#151515"/>
<path d="M26.2675 10.8048C26.2877 10.7837 26.2836 10.7731 26.255 10.7731H17.7975C17.7762 10.7731 17.7714 10.7638 17.783 10.7452L21.3221 5.03395C21.3317 5.01848 21.3453 5.01074 21.3629 5.01074H33.5155C33.5188 5.01074 33.5219 5.01213 33.5243 5.0146C33.5266 5.01708 33.5279 5.02043 33.5279 5.02393V10.7494C33.5279 10.7673 33.5218 10.7826 33.5095 10.7953C31.0839 13.3392 28.8027 16.2518 27.1169 19.4365C26.3193 20.944 25.6677 22.6434 25.4834 24.3571C25.4296 24.8592 25.4445 25.4268 25.445 25.9632C25.445 25.9811 25.4367 25.9901 25.4201 25.9901H18.4337C18.4164 25.9901 18.4072 25.981 18.4063 25.9627C18.3571 24.9535 18.4315 23.9955 18.6294 23.0886C19.0753 21.0468 20.0567 19.0346 21.1572 17.2724C22.6248 14.9221 24.3809 12.7806 26.2675 10.8048Z" fill="#70B62B"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
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
@@ -32,6 +33,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MenuDefaults
@@ -46,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -328,8 +331,8 @@ fun MailNavigationSidebar(
Column(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.8f)
.background(F7Colors.Surface)
.fillMaxWidth(0.9f)
.background(F7Colors.Background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 12.dp),
) {
@@ -422,7 +425,10 @@ fun MailNavigationSidebar(
expanded = !collapsed,
onToggleExpanded = { onToggleAccountCollapsed(account.id) },
)
Spacer(Modifier.height(4.dp))
HorizontalDivider(
modifier = Modifier.padding(bottom = 8.dp),
color = F7Colors.Border,
)
}
if (!collapsed) {
accountFolders.forEach { folder ->
@@ -533,14 +539,17 @@ private fun MailSidebarRow(
title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (unread != null && unread > 0) {
// Живая тема: счётчик 16/500, серый, без фона
Text(
unread.toString(),
style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextSecondary,
)
}
@@ -598,7 +607,9 @@ fun MailEnvelopeRow(
val sender = message.fromEmail.takeIf { it.isNotBlank() }
?: message.from.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
?: "Неизвестный"
val mutedColor = F7Colors.TextSecondary
// Живая тема (_mobile-mail.css): карточка #FDFDFD r8, нижняя граница #E6E6E6 + тень #CBCBCB,
// отправитель 16/500 чёрный, тема 14/600 и превью/дата 14/500 — чёрный 50%.
val fadedColor = Color(0x80151515)
val subject = message.subject.ifBlank { "(без темы)" }
Box(
@@ -606,14 +617,21 @@ fun MailEnvelopeRow(
.fillMaxWidth()
.shadow(
elevation = 1.dp,
shape = RoundedCornerShape(12.dp),
spotColor = Color(0x14000000),
shape = RoundedCornerShape(8.dp),
spotColor = Color(0xFFCBCBCB),
)
.clip(RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(8.dp))
.background(MailCardSurface)
.border(1.dp, F7Colors.Border.copy(alpha = 0.45f), RoundedCornerShape(12.dp))
.drawBehind {
drawLine(
color = Color(0xFFE6E6E6),
start = androidx.compose.ui.geometry.Offset(0f, size.height - 0.5f),
end = androidx.compose.ui.geometry.Offset(size.width, size.height - 0.5f),
strokeWidth = 1.dp.toPx(),
)
}
.clickable(onClick = onClick)
.padding(12.dp),
.padding(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -624,7 +642,7 @@ fun MailEnvelopeRow(
email = message.fromEmail,
modifier = Modifier.size(40.dp),
)
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -634,15 +652,15 @@ fun MailEnvelopeRow(
sender,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Medium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
formatEnvelopeDate(message.dateInt),
style = MaterialTheme.typography.labelSmall,
color = mutedColor,
style = MaterialTheme.typography.bodyMedium,
color = fadedColor,
)
if (message.flags.flagged) {
Spacer(Modifier.width(4.dp))
@@ -654,18 +672,27 @@ fun MailEnvelopeRow(
)
}
}
Spacer(Modifier.height(4.dp))
Text(
subject,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
color = mutedColor,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = fadedColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (message.preview.isNotBlank()) {
Text(
message.preview,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = fadedColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Теги письма цветными пилюлями (макет «Список сообщений»)
if (message.tags.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
message.tags.take(4).forEach { MailTagChip(it) }
}
@@ -955,6 +982,8 @@ fun MailMessageSenderCard(
onMove: () -> Unit,
onSnooze: () -> Unit,
modifier: Modifier = Modifier,
onCreateTask: (() -> Unit)? = null,
onCreateEvent: (() -> Unit)? = null,
) {
val base = serverUrl.trimEnd('/')
var headersExpanded by remember { mutableStateOf(false) }
@@ -987,15 +1016,15 @@ fun MailMessageSenderCard(
MailAvatar(
name = fromName,
email = fromEmail,
modifier = Modifier.size(44.dp),
modifier = Modifier.size(40.dp),
)
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
displayName,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1099,6 +1128,8 @@ fun MailMessageSenderCard(
onEditTags = onEditTags,
onMove = onMove,
onSnooze = onSnooze,
onCreateTask = onCreateTask,
onCreateEvent = onCreateEvent,
)
}
}
@@ -1158,6 +1189,8 @@ fun MailMessageActionsMenu(
onEditTags: () -> Unit,
onMove: () -> Unit,
onSnooze: () -> Unit,
onCreateTask: (() -> Unit)? = null,
onCreateEvent: (() -> Unit)? = null,
) {
DropdownMenu(
expanded = expanded,
@@ -1211,6 +1244,18 @@ fun MailMessageActionsMenu(
onDismiss()
onEditTags()
}
onCreateTask?.let { create ->
MailMessageIconMenuItem(serverUrl, "create-task-black.svg", "Создать задачу") {
onDismiss()
create()
}
}
onCreateEvent?.let { create ->
MailMessageIconMenuItem(serverUrl, "calendar-black.svg", "Создать событие") {
onDismiss()
create()
}
}
MailMessageIconMenuItem(serverUrl, "folder-black.svg", "Переместить сообщение") {
onDismiss()
onMove()
@@ -1302,13 +1347,15 @@ fun MailMessageTagsSheet(
shape = RoundedCornerShape(16.dp),
color = Color.White,
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Живая тема: заголовок модалки 18/600; строки тегов — плашки #F5F5F5 r10
// (minHeight 36), имя тега — цветная пилюля.
Text(
"Изменить метки",
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Spacer(Modifier.height(12.dp))
if (availableTags.isEmpty()) {
Text("Нет доступных меток", color = F7Colors.TextSecondary)
} else {
@@ -1317,17 +1364,20 @@ fun MailMessageTagsSheet(
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.SurfaceMuted)
.clickable { onToggle(tag) }
.padding(vertical = 6.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = checked,
onCheckedChange = { onToggle(tag) },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Text(tag.displayName, style = MaterialTheme.typography.bodyMedium)
MailTagChip(tag)
}
}
}
@@ -331,8 +331,8 @@ private fun MailComposeHeader(
Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -457,11 +457,11 @@ private fun MailComposeField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
textStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(label, color = F7Colors.TextSecondary, fontSize = 15.sp)
Text(label, color = F7Colors.TextSecondary, fontSize = 14.sp)
}
inner()
},
@@ -507,10 +507,11 @@ private fun MailComposeFormattingToolbar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(10.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(10.dp))
.background(Color.White)
.padding(8.dp),
.shadow(1.dp, RoundedCornerShape(8.dp), spotColor = F7Colors.Border)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color(0xFFFDFDFD))
.padding(4.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
@@ -0,0 +1,325 @@
package ru.forbion.f7cloud.feature.mail
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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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 java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/** Выбор из вариантов «плашками» — как строки в модалках живой темы (#F5F5F5, r10, 36dp). */
@Composable
private fun <T> PickerRows(
options: List<T>,
selected: T?,
label: (T) -> String,
onSelect: (T) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
options.forEach { option ->
val isSelected = option == selected
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (isSelected) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
.then(
if (isSelected) {
Modifier.border(
1.dp,
F7Colors.Primary.copy(alpha = 0.25f),
RoundedCornerShape(10.dp),
)
} else {
Modifier
},
)
.clickable { onSelect(option) }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label(option),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun CreateSheetScaffold(
title: String,
onDismiss: () -> Unit,
content: @Composable () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.94f)
.heightIn(max = 620.dp),
shape = RoundedCornerShape(16.dp),
color = Color.White,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Живая тема: заголовки модалок 18/600
Text(
title,
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
content()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DueDateRow(dueDate: LocalDate?, onChange: (LocalDate?) -> Unit) {
var pickerOpen by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.SurfaceMuted)
.clickable { pickerOpen = true }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
dueDate?.format(DateTimeFormatter.ofPattern("d MMMM yyyy"))
?: "Срок не задан (нажмите, чтобы выбрать)",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = if (dueDate != null) F7Colors.TextPrimary else F7Colors.TextSecondary,
)
if (dueDate != null) {
Text(
"Сбросить",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.Primary,
modifier = Modifier.clickable { onChange(null) },
)
}
}
if (pickerOpen) {
val state = rememberDatePickerState(
initialSelectedDateMillis = (dueDate ?: LocalDate.now())
.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { pickerOpen = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { millis ->
onChange(
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate(),
)
}
pickerOpen = false
}) { Text("ОК", color = F7Colors.Primary) }
},
dismissButton = {
TextButton(onClick = { pickerOpen = false }) {
Text("Отмена", color = F7Colors.TextSecondary)
}
},
) {
DatePicker(state = state)
}
}
}
/** Модалка «Создать задачу» из письма (паритет живого веба). */
@Composable
fun MailCreateTaskSheet(
visible: Boolean,
taskLists: List<MailTargetList>,
defaultSummary: String,
defaultDescription: String,
creating: Boolean,
onDismiss: () -> Unit,
onCreate: (listHref: String, summary: String, description: String, due: LocalDate?) -> Unit,
) {
if (!visible) return
var summary by rememberSaveable(defaultSummary) { mutableStateOf(defaultSummary) }
var description by rememberSaveable(defaultDescription) { mutableStateOf(defaultDescription) }
var dueDate by remember { mutableStateOf<LocalDate?>(null) }
var selectedList by remember(taskLists) { mutableStateOf(taskLists.firstOrNull()) }
CreateSheetScaffold(title = "Создать задачу", onDismiss = onDismiss) {
F7OutlinedField(value = summary, onValueChange = { summary = it }, label = "Название")
F7OutlinedField(
value = description,
onValueChange = { description = it },
label = "Описание",
minLines = 3,
)
if (taskLists.isNotEmpty()) {
Text("Список задач", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
PickerRows(
options = taskLists,
selected = selectedList,
label = { it.name },
onSelect = { selectedList = it },
)
}
Text("Срок", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
DueDateRow(dueDate) { dueDate = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(
text = if (creating) "Создание…" else "Создать",
onClick = {
selectedList?.let { onCreate(it.href, summary.trim(), description.trim(), dueDate) }
},
enabled = !creating && summary.isNotBlank() && selectedList != null,
modifier = Modifier.weight(1f),
)
}
}
}
/** Модалка «Создать событие» из письма (паритет живого веба). */
@Composable
fun MailCreateEventSheet(
visible: Boolean,
calendars: List<MailTargetList>,
defaultTitle: String,
defaultDescription: String,
creating: Boolean,
onDismiss: () -> Unit,
onCreate: (
calendarHref: String,
title: String,
description: String,
date: LocalDate,
startTime: String,
endTime: String,
allDay: Boolean,
) -> Unit,
) {
if (!visible) return
var title by rememberSaveable(defaultTitle) { mutableStateOf(defaultTitle) }
var description by rememberSaveable(defaultDescription) { mutableStateOf(defaultDescription) }
var date by remember { mutableStateOf(LocalDate.now()) }
var startTime by rememberSaveable { mutableStateOf("10:00") }
var endTime by rememberSaveable { mutableStateOf("11:00") }
var allDay by rememberSaveable { mutableStateOf(false) }
var selectedCalendar by remember(calendars) { mutableStateOf(calendars.firstOrNull()) }
CreateSheetScaffold(title = "Создать событие", onDismiss = onDismiss) {
F7OutlinedField(value = title, onValueChange = { title = it }, label = "Название")
Text("Дата", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
DueDateRow(date) { picked -> picked?.let { date = it } }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Checkbox(
checked = allDay,
onCheckedChange = { allDay = it },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Text("Весь день", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
if (!allDay) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.weight(1f)) {
F7OutlinedField(value = startTime, onValueChange = { startTime = it }, label = "Начало (ЧЧ:ММ)")
}
Box(modifier = Modifier.weight(1f)) {
F7OutlinedField(value = endTime, onValueChange = { endTime = it }, label = "Конец (ЧЧ:ММ)")
}
}
}
if (calendars.isNotEmpty()) {
Text("Календарь", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
PickerRows(
options = calendars,
selected = selectedCalendar,
label = { it.name },
onSelect = { selectedCalendar = it },
)
}
F7OutlinedField(
value = description,
onValueChange = { description = it },
label = "Описание",
minLines = 3,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(
text = if (creating) "Создание…" else "Создать",
onClick = {
selectedCalendar?.let {
onCreate(it.href, title.trim(), description.trim(), date, startTime, endTime, allDay)
}
},
enabled = !creating && title.isNotBlank() && selectedCalendar != null,
modifier = Modifier.weight(1f),
)
}
}
}
/** Цель создания (список задач или календарь): href + имя. */
data class MailTargetList(val href: String, val name: String)
@@ -3,16 +3,24 @@ package ru.forbion.f7cloud.feature.mail
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.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.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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -31,6 +39,13 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.feature.contacts.ContactItem
import ru.forbion.f7cloud.feature.contacts.ContactListRow
/**
* Поле получателей с чипами как в живой теме (_mobile-mail.css):
* подтверждённые адреса (сегменты строки через запятую) пилюли 26dp
* (фон #70B62B 12%, рамка 25%, текст 13/18, крестик 14), хвост инлайн-ввод.
* Значение остаётся строкой «a, b, черновик» VM/parseRecipients не меняются.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MailRecipientComposeField(
label: String,
@@ -43,6 +58,19 @@ fun MailRecipientComposeField(
trailingIconUrl: String? = null,
onTrailingClick: (() -> Unit)? = null,
) {
val parts = value.split(',').map { it.trim() }
val draft = if (value.trimEnd().endsWith(",")) "" else parts.lastOrNull().orEmpty()
val committed = (if (draft.isEmpty()) parts else parts.dropLast(1)).filter { it.isNotBlank() }
fun rebuild(newCommitted: List<String>, newDraft: String): String {
val head = newCommitted.joinToString(", ")
return when {
head.isEmpty() -> newDraft
newDraft.isEmpty() -> "$head, "
else -> "$head, $newDraft"
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
@@ -51,27 +79,55 @@ fun MailRecipientComposeField(
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color.White)
.padding(horizontal = 12.dp, vertical = 10.dp),
.heightIn(min = 40.dp)
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
FlowRow(
modifier = Modifier.weight(1f),
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(label, color = F7Colors.TextSecondary, fontSize = 15.sp)
}
inner()
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
) {
committed.forEachIndexed { index, recipient ->
RecipientChip(
text = recipient,
onRemove = {
val rest = committed.toMutableList().also { it.removeAt(index) }
onValueChange(rebuild(rest, draft))
},
)
}
BasicTextField(
value = draft,
onValueChange = { new -> onValueChange(rebuild(committed, new)) },
modifier = Modifier
.widthIn(min = 80.dp)
.weight(1f)
.height(26.dp)
.padding(horizontal = 4.dp),
singleLine = true,
textStyle = TextStyle(
fontSize = 14.sp,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
Box(contentAlignment = Alignment.CenterStart, modifier = Modifier.height(26.dp)) {
if (draft.isEmpty() && committed.isEmpty()) {
Text(label, color = F7Colors.TextSecondary, fontSize = 14.sp)
}
inner()
}
},
)
}
if (trailingIconUrl != null && onTrailingClick != null) {
AsyncImage(
model = trailingIconUrl,
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
.size(16.dp)
.clickable(onClick = onTrailingClick),
contentScale = ContentScale.Fit,
@@ -107,3 +163,39 @@ fun MailRecipientComposeField(
}
}
}
@Composable
private fun RecipientChip(text: String, onRemove: () -> Unit) {
Row(
modifier = Modifier
.height(26.dp)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary.copy(alpha = 0.12f))
.border(1.dp, F7Colors.Primary.copy(alpha = 0.25f), RoundedCornerShape(100.dp))
.padding(start = 6.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
) {
Text(
text,
fontSize = 13.sp,
lineHeight = 18.sp,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
)
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Убрать получателя",
tint = F7Colors.TextPrimary.copy(alpha = 0.5f),
modifier = Modifier
.size(14.dp)
.clickable(
interactionSource = androidx.compose.runtime.remember { MutableInteractionSource() },
indication = null,
onClick = onRemove,
),
)
}
}
@@ -967,7 +967,115 @@ class MailRepository {
}.sortedByDescending { it.updatedAt }
}
// --- Фильтры почты: официальный API NC Mail (тот же, что у веб-модалки «Новый фильтр») ---
// GET/PUT /apps/mail/api/filter/{accountId}; сервер сам сериализует в sieve-скрипт
// (секция «### Nextcloud Mail: Filters ###»), правила ОБЩИЕ между вебом и приложением.
fun getMailFilters(session: AuthSession, accountId: Int): List<MailFilterRule> {
val client = authedClient(session)
val array = getJsonArray(client, "${apiBase(session)}/filter/$accountId")
return buildList {
for (i in 0 until array.length()) {
array.optJSONObject(i)?.let { add(MailFilterRule(it)) }
}
}
}
fun putMailFilters(session: AuthSession, accountId: Int, filters: List<MailFilterRule>) {
val client = authedClient(session)
val payload = JSONObject()
.put("filters", JSONArray().apply { filters.forEach { put(it.raw) } })
.toString()
val request = Request.Builder()
.url("${apiBase(session)}/filter/$accountId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.put(payload.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Filter API HTTP ${response.code}")
}
}
}
companion object {
const val PAGE_SIZE = 20
}
}
/**
* Правило фильтра почты. Держим исходный JSON (raw) целиком, чтобы при правке
* простых полей не терять незнакомые поля сложных правил, созданных в вебе.
*/
class MailFilterRule(val raw: JSONObject) {
val name: String get() = raw.optString("name")
val enable: Boolean get() = raw.optBoolean("enable", true)
val operator: String get() = raw.optString("operator", "allof")
val priority: Int get() = raw.optInt("priority", 10)
val tests: List<Triple<String, String, List<String>>>
get() = raw.optJSONArray("tests")?.let { arr ->
buildList {
for (i in 0 until arr.length()) {
val t = arr.optJSONObject(i) ?: continue
val values = t.optJSONArray("values")?.let { v ->
(0 until v.length()).map { v.optString(it) }
}.orEmpty()
add(Triple(t.optString("field"), t.optString("operator", "contains"), values))
}
}
}.orEmpty()
val actions: List<JSONObject>
get() = raw.optJSONArray("actions")?.let { arr ->
(0 until arr.length()).mapNotNull { arr.optJSONObject(it) }
}.orEmpty()
/** Простое правило (1 тест, действия из нашего конструктора) — редактируемо инлайн. */
val isSimple: Boolean
get() = tests.size == 1 && actions.all {
it.optString("type") in setOf("fileinto", "addflag", "addsystemflag")
}
fun withEnable(value: Boolean): MailFilterRule =
MailFilterRule(JSONObject(raw.toString()).put("enable", value))
companion object {
fun simple(
name: String,
field: String,
contains: String,
action: String,
targetFolder: String,
priority: Int = 10,
): MailFilterRule {
val actions = JSONArray()
when (action) {
"markread" -> actions.put(
JSONObject().put("type", "addsystemflag").put("flag", "\\Seen"),
)
else -> actions.put(
JSONObject().put("type", "fileinto").put("mailbox", targetFolder),
)
}
return MailFilterRule(
JSONObject()
.put("name", name)
.put("enable", true)
.put("operator", "allof")
.put("priority", priority)
.put(
"tests",
JSONArray().put(
JSONObject()
.put("field", field)
.put("operator", "contains")
.put("values", JSONArray().put(contains)),
),
)
.put("actions", actions),
)
}
}
}
@@ -24,6 +24,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
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
@@ -46,6 +48,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import androidx.activity.compose.BackHandler
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ErrorState
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -246,6 +249,23 @@ fun MailScreen(
onSettingsOpenChange(false)
},
onSwipeBack = { mailNavigateBack() },
onOpenSieveFilters = { accountId -> vm.openSieveFilters(session, accountId) },
)
MailSieveFiltersSheet(
visible = state.sieveAccountId != null,
rules = state.sieveRules,
folders = state.mailboxes
.filter { it.accountId == state.sieveAccountId && !it.isInbox && it.specialRole.isNullOrBlank() }
.map { it.displayName },
loading = state.sieveLoading,
saving = state.sieveSaving,
error = state.sieveError,
onDismiss = { vm.closeSieveFilters() },
onAdd = { rule -> vm.addFilterRule(session, rule) },
onUpdate = { index, rule -> vm.updateFilterRule(session, index, rule) },
onDelete = { index -> vm.deleteFilterRule(session, index) },
onToggle = { index, enable -> vm.toggleFilterRule(session, index, enable) },
)
BackHandler(enabled = mailCanGoBack) {
@@ -462,6 +482,34 @@ fun MailScreen(
openScheduledReplyForMessage(detail, option)
},
)
// «Создать задачу/событие из письма» — паритет живого веба:
// префилл темой письма и отправителем.
val createDefaults = state.messageDetail?.let { detail ->
detail.subject.ifBlank { "(без темы)" } to
"Из письма от ${detail.from.ifBlank { detail.fromEmail }}"
}
MailCreateTaskSheet(
visible = state.createTaskSheetOpen,
taskLists = state.createTargets,
defaultSummary = createDefaults?.first.orEmpty(),
defaultDescription = createDefaults?.second.orEmpty(),
creating = state.creatingFromMessage,
onDismiss = vm::dismissCreateSheets,
onCreate = { listHref, summary, description, due ->
vm.createTaskFromMessage(session, listHref, summary, description, due)
},
)
MailCreateEventSheet(
visible = state.createEventSheetOpen,
calendars = state.createTargets,
defaultTitle = createDefaults?.first.orEmpty(),
defaultDescription = createDefaults?.second.orEmpty(),
creating = state.creatingFromMessage,
onDismiss = vm::dismissCreateSheets,
onCreate = { calHref, title, description, date, start, end, allDay ->
vm.createEventFromMessage(session, calHref, title, description, date, start, end, allDay)
},
)
}
} else {
MailInboxScreen(
@@ -485,6 +533,8 @@ fun MailScreen(
onOutboxRetry = { vm.retryOutboxMessage(session, it) },
onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
onLoadMore = { vm.loadMore(session) },
refreshing = state.loading,
onRefresh = { vm.load(session, forceRefresh = true) },
)
}
MailNavigationSidebar(
@@ -520,6 +570,7 @@ fun MailScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MailInboxScreen(
session: AuthSession,
@@ -542,6 +593,8 @@ private fun MailInboxScreen(
onOutboxRetry: (Int) -> Unit,
onOutboxDelete: (Int) -> Unit,
onLoadMore: () -> Unit,
refreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
val listItems = remember(messages) { buildMailInboxListItems(messages) }
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
@@ -592,14 +645,14 @@ private fun MailInboxScreen(
color = F7Colors.TextPrimary,
)
}
if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
Box(
PullToRefreshBox(
isRefreshing = refreshing && !loading,
onRefresh = onRefresh,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (loading) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -633,11 +686,25 @@ private fun MailInboxScreen(
}
return@Box
}
if (messages.isEmpty()) {
Box(
if (messages.isEmpty() && !error.isNullOrBlank()) {
F7ErrorState(
message = error,
onRetry = onRefresh,
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
)
} else if (messages.isEmpty()) {
// Живая тема: пустая папка — цветная иконка почты 64dp + текст 16/20
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
coil.compose.AsyncImage(
model = "file:///android_asset/mailchrome/mail-icon-color.svg",
contentDescription = null,
modifier = Modifier.size(64.dp),
)
Spacer(Modifier.height(12.dp))
Text(
if (searchQuery.isNotBlank() || searchParams.isActive()) {
"Ничего не найдено"
@@ -666,7 +733,8 @@ private fun MailInboxScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
// Живая тема: зазор между карточками писем 2px
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(
listItems,
@@ -707,6 +775,7 @@ private fun MailInboxScreen(
}
}
}
}
@Composable
private fun MailMessageDetailHeader(
@@ -759,6 +828,8 @@ private fun MailMessageDetailHeader(
onEditTags = onTagsSheetOpen,
onMove = onMoveSheetOpen,
onSnooze = onSnoozeSheetOpen,
onCreateTask = { vm.openCreateTaskSheet(session) },
onCreateEvent = { vm.openCreateEventSheet(session) },
)
}
@@ -77,6 +77,7 @@ fun MailSettingsSheet(
onDeleteTextBlock: (Int) -> Unit,
onDismiss: () -> Unit,
onSwipeBack: () -> Unit = onDismiss,
onOpenSieveFilters: ((Int) -> Unit)? = null,
) {
if (!visible) return
LaunchedEffect(visible, editingAccountId) {
@@ -102,6 +103,9 @@ fun MailSettingsSheet(
mailboxes = mailboxes.filter { it.accountId == editingAccountId },
onBack = onAccountSettingsBack,
onSave = onSaveAccountSettings,
onOpenSieveFilters = onOpenSieveFilters?.let { open ->
{ open(editingAccountId) }
},
)
} else {
MailAppSettingsScreen(
@@ -517,6 +521,7 @@ private fun MailAccountSettingsScreen(
mailboxes: List<MailMailbox>,
onBack: () -> Unit,
onSave: (MailAccountSettings) -> Unit,
onOpenSieveFilters: (() -> Unit)? = null,
) {
var draft by remember { mutableStateOf<MailAccountSettings?>(null) }
LaunchedEffect(settings) {
@@ -567,6 +572,27 @@ private fun MailAccountSettingsScreen(
draft!!.quotaPercentage?.let { quota ->
MailSettingsInfoRow("Квота", "$quota%")
}
onOpenSieveFilters?.let { open ->
// Sieve-фильтры (паритет живого веба)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.SurfaceMuted)
.clickable(onClick = open)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Фильтры",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
)
Text("", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextSecondary)
}
}
MailSettingsTextArea(
label = "Подпись",
value = draft!!.signature,
@@ -0,0 +1,306 @@
package ru.forbion.f7cloud.feature.mail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.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
private val FIELD_OPTIONS = listOf("from" to "От", "subject" to "Тема", "to" to "Кому")
private val ACTION_OPTIONS = listOf(
"fileinto" to "Переместить в папку",
"markread" to "Пометить прочитанным",
)
/** Пилюля-переключатель варианта (чипы живой темы: актив — #ECF9DE). */
@Composable
private fun OptionChip(text: String, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.heightIn(min = 25.dp)
.clip(RoundedCornerShape(100.dp))
.background(if (selected) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
private fun ruleSummary(rule: MailFilterRule): String {
val tests = rule.tests.joinToString("; ") { (field, op, values) ->
val fieldLabel = FIELD_OPTIONS.firstOrNull { it.first == field }?.second ?: field
"$fieldLabel $op «${values.joinToString(", ")}»"
}
val actions = rule.actions.joinToString("; ") { action ->
when (action.optString("type")) {
"fileinto" -> "в папку «${action.optString("mailbox")}»"
"addflag", "addsystemflag" ->
if (action.optString("flag").contains("Seen")) "пометить прочитанным"
else "флаг «${action.optString("flag")}»"
"keep" -> "оставить"
"stop" -> "стоп"
else -> action.optString("type")
}
}
return "$tests$actions"
}
/**
* Фильтры почты общие с веб-версией (официальный API NC Mail).
* Список всех правил (вкл/выкл, удаление), редактирование простых, конструктор нового.
*/
@Composable
fun MailSieveFiltersSheet(
visible: Boolean,
rules: List<MailFilterRule>,
folders: List<String>,
loading: Boolean,
saving: Boolean,
error: String?,
onDismiss: () -> Unit,
onAdd: (MailFilterRule) -> Unit,
onUpdate: (Int, MailFilterRule) -> Unit,
onDelete: (Int) -> Unit,
onToggle: (Int, Boolean) -> Unit,
) {
if (!visible) return
var name by rememberSaveable { mutableStateOf("") }
var field by rememberSaveable { mutableStateOf("subject") }
var contains by rememberSaveable { mutableStateOf("") }
var action by rememberSaveable { mutableStateOf("fileinto") }
var targetFolder by rememberSaveable(folders) { mutableStateOf(folders.firstOrNull().orEmpty()) }
// -1 = создание нового; иначе — индекс редактируемого правила
var editingIndex by rememberSaveable { mutableIntStateOf(-1) }
fun loadIntoForm(rule: MailFilterRule) {
name = rule.name
rule.tests.firstOrNull()?.let { (f, _, values) ->
field = f
contains = values.joinToString(", ")
}
val act = rule.actions.firstOrNull()
action = if (act?.optString("type") == "fileinto") "fileinto" else "markread"
if (act?.optString("type") == "fileinto") {
targetFolder = act.optString("mailbox")
}
}
fun clearForm() {
name = ""
contains = ""
editingIndex = -1
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.94f)
.heightIn(max = 660.dp),
shape = RoundedCornerShape(16.dp),
color = Color.White,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Фильтры",
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
when {
loading -> Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
else -> {
if (rules.isEmpty()) {
Text(
"Фильтров пока нет.",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
rules.forEachIndexed { index, rule ->
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(10.dp))
.background(
if (editingIndex == index) F7Colors.PrimaryLight else F7Colors.SurfaceMuted,
)
.clickable(enabled = !saving && rule.isSimple) {
editingIndex = index
loadIntoForm(rule)
}
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
rule.name.ifBlank { "Без названия" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
ruleSummary(rule) +
if (!rule.isSimple) " (сложное — правится в вебе)" else "",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Switch(
checked = rule.enable,
onCheckedChange = { onToggle(index, it) },
enabled = !saving,
colors = SwitchDefaults.colors(checkedTrackColor = F7Colors.Primary),
)
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Удалить фильтр",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(16.dp)
.clickable(enabled = !saving) {
if (editingIndex == index) clearForm()
onDelete(index)
},
)
}
}
Text(
if (editingIndex >= 0) "Изменить фильтр" else "Новый фильтр",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
)
F7OutlinedField(value = name, onValueChange = { name = it }, label = "Название")
Text("Если", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FIELD_OPTIONS.forEach { (value, label) ->
OptionChip(label, selected = field == value) { field = value }
}
}
F7OutlinedField(value = contains, onValueChange = { contains = it }, label = "Содержит")
Text("То", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ACTION_OPTIONS.forEach { (value, label) ->
OptionChip(label, selected = action == value) { action = value }
}
}
if (action == "fileinto" && folders.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
folders.forEach { folder ->
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 32.dp)
.clip(RoundedCornerShape(10.dp))
.background(
if (folder == targetFolder) F7Colors.PrimaryLight else F7Colors.SurfaceMuted,
)
.clickable { targetFolder = folder }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
folder,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
}
}
}
}
if (!error.isNullOrBlank()) {
Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton(
if (editingIndex >= 0) "Отменить правку" else "Закрыть",
onClick = { if (editingIndex >= 0) clearForm() else onDismiss() },
modifier = Modifier.weight(1f),
)
F7PrimaryButton(
text = when {
saving -> "Сохранение…"
editingIndex >= 0 -> "Сохранить"
else -> "Создать"
},
onClick = {
val rule = MailFilterRule.simple(
name = name.trim().ifBlank { "Фильтр" },
field = field,
contains = contains.trim(),
action = action,
targetFolder = if (action == "fileinto") targetFolder else "",
)
if (editingIndex >= 0) onUpdate(editingIndex, rule) else onAdd(rule)
clearForm()
},
enabled = !saving && contains.isNotBlank() &&
(action != "fileinto" || targetFolder.isNotBlank()),
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
@@ -11,6 +11,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
@@ -44,6 +45,17 @@ data class MailUiState(
val appSettingsSaving: Boolean = false,
val error: String? = null,
val unauthorized: Boolean = false,
// «Создать задачу/событие из письма» (паритет живого веба)
val createTaskSheetOpen: Boolean = false,
val createEventSheetOpen: Boolean = false,
val createTargets: List<MailTargetList> = emptyList(),
val creatingFromMessage: Boolean = false,
// Фильтры почты (официальный API NC Mail — общие с вебом)
val sieveAccountId: Int? = null,
val sieveRules: List<MailFilterRule> = emptyList(),
val sieveLoading: Boolean = false,
val sieveSaving: Boolean = false,
val sieveError: String? = null,
)
class MailViewModel(
@@ -995,6 +1007,172 @@ class MailViewModel(
)
}
// --- «Создать задачу/событие из письма» (паритет живого веба) ---
fun openCreateTaskSheet(session: AuthSession) {
_state.update { it.copy(createTaskSheetOpen = true, createTargets = emptyList()) }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
ru.forbion.f7cloud.feature.tasks.TasksRepository()
.listTaskLists(session)
.map { MailTargetList(it.href, it.displayName) }
}.onSuccess { lists ->
_state.update { it.copy(createTargets = lists) }
}.onFailure { t ->
_state.update { it.copy(createTaskSheetOpen = false, error = t.message) }
}
}
}
fun openCreateEventSheet(session: AuthSession) {
_state.update { it.copy(createEventSheetOpen = true, createTargets = emptyList()) }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
ru.forbion.f7cloud.feature.calendar.CalendarRepository()
.listCalendars(session)
.map { MailTargetList(it.href, it.displayName) }
}.onSuccess { calendars ->
_state.update { it.copy(createTargets = calendars) }
}.onFailure { t ->
_state.update { it.copy(createEventSheetOpen = false, error = t.message) }
}
}
}
fun dismissCreateSheets() {
_state.update { it.copy(createTaskSheetOpen = false, createEventSheetOpen = false) }
}
fun createTaskFromMessage(
session: AuthSession,
listHref: String,
summary: String,
description: String,
due: java.time.LocalDate?,
) {
_state.update { it.copy(creatingFromMessage = true) }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
ru.forbion.f7cloud.feature.tasks.TasksRepository().createTask(
session = session,
listHref = listHref,
summary = summary,
dueDate = due,
description = description,
)
}.onSuccess {
_state.update {
it.copy(creatingFromMessage = false, createTaskSheetOpen = false, error = null)
}
}.onFailure { t ->
_state.update { it.copy(creatingFromMessage = false, error = t.message) }
}
}
}
fun createEventFromMessage(
session: AuthSession,
calendarHref: String,
title: String,
description: String,
date: java.time.LocalDate,
startTime: String,
endTime: String,
allDay: Boolean,
) {
_state.update { it.copy(creatingFromMessage = true) }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
ru.forbion.f7cloud.feature.calendar.CalendarRepository().saveEvent(
session,
ru.forbion.f7cloud.feature.calendar.CalendarEventDraft(
title = title,
date = date,
startTime = startTime,
endTime = endTime,
allDay = allDay,
description = description,
calendarHref = calendarHref,
),
)
}.onSuccess {
_state.update {
it.copy(creatingFromMessage = false, createEventSheetOpen = false, error = null)
}
}.onFailure { t ->
_state.update { it.copy(creatingFromMessage = false, error = t.message) }
}
}
}
// --- Фильтры почты (общие с вебом, API /api/filter/{accountId}) ---
fun openSieveFilters(session: AuthSession, accountId: Int) {
_state.update {
it.copy(sieveAccountId = accountId, sieveRules = emptyList(), sieveLoading = true, sieveError = null)
}
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.getMailFilters(session, accountId)
}.onSuccess { rules ->
_state.update { it.copy(sieveRules = rules, sieveLoading = false) }
}.onFailure { t ->
_state.update {
it.copy(sieveLoading = false, sieveError = t.message ?: "Фильтры недоступны")
}
}
}
}
fun closeSieveFilters() {
_state.update { it.copy(sieveAccountId = null, sieveError = null) }
}
fun addFilterRule(session: AuthSession, rule: MailFilterRule) {
saveFilterRules(session) { it + rule }
}
fun updateFilterRule(session: AuthSession, index: Int, rule: MailFilterRule) {
saveFilterRules(session) { rules ->
rules.toMutableList().also { if (index in it.indices) it[index] = rule }
}
}
fun deleteFilterRule(session: AuthSession, index: Int) {
saveFilterRules(session) { rules ->
rules.toMutableList().also { if (index in it.indices) it.removeAt(index) }
}
}
fun toggleFilterRule(session: AuthSession, index: Int, enable: Boolean) {
saveFilterRules(session) { rules ->
rules.toMutableList().also {
if (index in it.indices) it[index] = it[index].withEnable(enable)
}
}
}
private fun saveFilterRules(
session: AuthSession,
transform: (List<MailFilterRule>) -> List<MailFilterRule>,
) {
val accountId = _state.value.sieveAccountId ?: return
// Правки применяем к ТЕКУЩЕМУ локальному списку (он же только что с сервера);
// сервер хранит фильтры единым массивом — PUT перезаписывает секцию приложения Mail.
val rules = transform(_state.value.sieveRules)
_state.update { it.copy(sieveSaving = true, sieveError = null) }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.putMailFilters(session, accountId, rules)
repository.getMailFilters(session, accountId)
}.onSuccess { fresh ->
_state.update { it.copy(sieveRules = fresh, sieveSaving = false) }
}.onFailure { t ->
_state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") }
}
}
}
companion object {
private const val MARK_READ_DELAY_MS = 2000L
private const val FOLDER_CACHE_TTL_MS = 15 * 60 * 1000L
@@ -0,0 +1,52 @@
package ru.forbion.f7cloud.feature.mail
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MailFilterRuleTest {
@Test
fun simple_rule_builds_web_compatible_json() {
val rule = MailFilterRule.simple(
name = "Работа",
field = "subject",
contains = "Отчёт",
action = "fileinto",
targetFolder = "Work",
)
assertEquals("Работа", rule.name)
assertTrue(rule.enable)
assertEquals("allof", rule.operator)
assertEquals(listOf(Triple("subject", "contains", listOf("Отчёт"))), rule.tests)
assertEquals("fileinto", rule.actions.single().optString("type"))
assertEquals("Work", rule.actions.single().optString("mailbox"))
assertTrue(rule.isSimple)
}
@Test
fun markread_uses_system_flag() {
val rule = MailFilterRule.simple("r", "from", "boss", "markread", "")
val action = rule.actions.single()
assertEquals("addsystemflag", action.optString("type"))
assertTrue(action.optString("flag").contains("Seen"))
}
@Test
fun withEnable_preserves_unknown_fields() {
val raw = JSONObject(
"""{"name":"web","enable":true,"operator":"anyof","priority":20,
"tests":[{"field":"subject","operator":"is","values":["x"]},
{"field":"from","operator":"contains","values":["y"]}],
"actions":[{"type":"stop"}],"customField":"keepme"}""",
)
val rule = MailFilterRule(raw)
assertFalse(rule.isSimple)
val disabled = rule.withEnable(false)
assertFalse(disabled.enable)
assertEquals("keepme", disabled.raw.optString("customField"))
assertEquals(2, disabled.tests.size)
}
}
@@ -5,6 +5,10 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.f7cloud.talk.activities.CallActivity
import ru.f7cloud.talk.services.CallForegroundService
import ru.f7cloud.talk.utils.bundle.BundleKeys
@@ -22,6 +26,9 @@ object TalkNativeCallLauncher {
private const val TAG = "TalkNativeCallLauncher"
private const val WEBVIEW_CALL_ACTIVITY = "ru.forbion.f7cloud.feature.talk.TalkCallActivity"
// Main-scope для запуска звонка после suspend-подготовки vendor-юзера (IO)
private val launchScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun launchIncomingCall(
context: Context,
session: AuthSession,
@@ -83,15 +90,18 @@ object TalkNativeCallLauncher {
}
return
}
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
showError(context, "Не удалось подготовить Talk для звонка")
return
}
val token = callContext.roomToken.trim()
if (token.isBlank()) {
showError(context, "Некорректная комната звонка")
return
}
// Подготовка vendor-юзера — suspend (IO): раньше runBlocking держал main
// на пути звонка (ANR-риск на входящем звонке, аудит C4). Запуск activity — на main.
launchScope.launch {
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
showError(context, "Не удалось подготовить Talk для звонка")
return@launch
}
runCatching {
startCallActivity(
context,
@@ -106,6 +116,7 @@ object TalkNativeCallLauncher {
}
}
}
}
private fun buildCallIntent(
context: Context,
@@ -2,6 +2,10 @@ package ru.forbion.f7cloud.feature.talknative
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
@@ -10,15 +14,20 @@ import ru.forbion.f7cloud.core.auth.AuthStore
*/
object TalkVendorBootstrap {
private const val TAG = "TalkVendorBootstrap"
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Прогрев vendor-юзера на старте — в фоне (раньше блокировал main в Application.onCreate). */
fun onApplicationCreate(context: Context) {
if (!TalkNativeConfig.bootstrapVendorRuntime) return
val session = AuthStore(context).load() ?: return
runCatching { TalkVendorUserSync.ensureUser(context, session) }
val appContext = context.applicationContext
warmupScope.launch {
val session = AuthStore(appContext).load() ?: return@launch
runCatching { TalkVendorUserSync.ensureUser(appContext, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
}
}
fun ensureSessionReady(context: Context, session: AuthSession): Boolean {
suspend fun ensureSessionReady(context: Context, session: AuthSession): Boolean {
if (!TalkNativeConfig.bootstrapVendorRuntime) return true
return runCatching { TalkVendorUserSync.ensureUser(context, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
@@ -5,22 +5,25 @@ import android.util.Log
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.f7cloud.F7TalkUserSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AuthSession
/**
* Ensures talk-android [ru.f7cloud.talk.data.user.model.User] exists for F7cloud sessions.
* Required before native [ru.f7cloud.talk.activities.CallActivity] can run.
*
* suspend (IO) раньше был runBlocking на main (старт приложения и путь звонка),
* что давало ANR-риск из аудита (C4/п.7).
*/
object TalkVendorUserSync {
private const val TAG = "TalkVendorUserSync"
fun ensureUser(context: Context, session: AuthSession): Boolean {
suspend fun ensureUser(context: Context, session: AuthSession): Boolean {
if (context.applicationContext !is F7cloudTalkApplication) {
Log.w(TAG, "Application is not F7cloudTalkApplication, skip sync")
return false
}
return runBlocking(Dispatchers.IO) {
return withContext(Dispatchers.IO) {
runCatching {
F7TalkUserSync.get().ensureUser(
serverUrl = session.serverUrl,

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