24 Commits

Author SHA1 Message Date
b-dev-mobile 91aaaba5a0 chore: версия 0.5.144 (152) + CHANGELOG сводного релиза 2026-07-10 22:28:15 +00:00
b-dev-mobile e6a71de7a6 feat(calendar): пакет функционала — событие 1:1 с веб, вложения, повторение, шаринг, free/busy
- Русские месяцы в шапке; нативный DatePickerDialog (material3 крашился)
- 403 при PUT события: учёт supported-calendar-component-set (VTODO-коллекции
  не принимают VEVENT) + sabre-precondition в тексте ошибки
- Окно события по _calendar-edit-full-fields.css: pill-поля, крестик, зелёная
  галочка-сохранение, нативные пикеры даты/времени, высота 94% экрана
- Talk-беседа к событию: выбор существующей / создание публичной|приватной
- Вложения (ATTACH+FILENAME/FMTTYPE): «из файлов» (WebDAV-пикер) или загрузка
  с устройства в /Calendar; round-trip при редактировании события
- Кастомное повторение (_calendar-repeat-modal.css): интервал+частота, дни
  недели кружками, окончание никогда/до даты/N раз ↔ RRULE (parse/build/описание)
- Многодневные события (endDate) и несколько напоминаний (VALARM list)
- Управление календарём (✎ в сайдбаре): PROPPATCH имя/цвет, DELETE,
  шаринг через DAV oc:invite + OCS sharees (польз./группы, чтение/редакт.)
- Discard-диалог при закрытии редактора с несохранёнными изменениями
- Занятость участников: iTIP VFREEBUSY через DAV outbox, конфликты красным
2026-07-10 22:28:15 +00:00
b-dev-mobile 4b000d3b27 feat(files): веб-сессия Nextcloud для .whiteboard в WebView
Доска (whiteboard) открывается через веб-viewer, которому мало Basic-auth:
Vue-XHR ходят по session-cookie+CSRF. Теперь перед загрузкой web-файла
логинимся формой /login app-паролем (NextcloudWebSession, обходит 2FA),
переносим cookie в CookieManager и грузим страницу нативно — для web-файлов
отключено OkHttp-проксирование (оно теряло тело POST/PROPFIND → «нет файлов»).
Collabora-путь не тронут (WOPI-токен самодостаточен).
+NetworkFactory.newBaseBuilder — билдер на общем пуле без auth.
2026-07-10 22:26:38 +00:00
b-dev-mobile f70b1861bd chore: версия 0.5.137 (145)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:12:19 +00:00
b-dev-mobile 6cdef16952 fix(files): .whiteboard белый экран — не прятать chrome (сносился #body-user)
Скрипт HIDE_F7CLOUD_CHROME_JS делал hide(#body-user) — это ВЕСЬ контейнер
приложения Nextcloud; на Collabora-странице документ в отдельном
#documents-content, а на Files-странице (whiteboard) внутри #body-user
лежит и сам viewer → удалялось всё → белый экран. Теперь скрипт
инжектится ТОЛЬКО для Collabora (collaboraBaseUrl не пуст); для web-файлов
chrome не трогаем — доска рисуется в штатном viewer.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 05:58:00 +00:00
69 changed files with 3165 additions and 765 deletions
+4
View File
@@ -2,6 +2,10 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-10 | v0.5.144 (152) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.133143). **Whiteboard**: .whiteboard наконец открывается — реальная веб-сессия Nextcloud в WebView (login-flow app-паролем → cookie+CSRF), для web-файлов отключено OkHttp-проксирование (теряло тело POST/PROPFIND). **Календарь — большой пакет**: русские месяцы в шапке; тап по дате не крашит (нативный DatePickerDialog вместо material3); фикс 403 при создании события (учёт supported-calendar-component-set — событие не уходит в VTODO-коллекцию; в ошибках теперь sabre-precondition); окно события 1:1 с живой темой (pill-поля 40dp #FBFBFB/#E6E6E6, шапка с крестиком, зелёная галочка, высота 94% экрана); дата/время нативными пикерами; беседа Talk к событию (выбор существующей или создание публичной/приватной); вложения (ATTACH: «из файлов» с навигацией по WebDAV или загрузка с устройства в /Calendar; round-trip при редактировании); кастомное повторение (модалка: интервал+частота, кружки дней недели, окончание никогда/до даты/N раз ↔ честный RRULE); многодневные события (отдельная дата окончания); несколько напоминаний; управление календарём из сайдбара (✎: переименование, цвет, шаринг пользователям/группам через DAV oc:invite + OCS sharees, удаление с подтверждением; подписки read-only); discard-диалог при закрытии с несохранёнными изменениями; занятость участников (iTIP VFREEBUSY через DAV outbox, конфликты подсвечены). **Задачи** (из 133–137): подзадачи (RELATED-TO), меню «⋯» (вложенная задача/удаление), стрелка панели открывает списки, кэш ускоряет загрузку. **Файлы** (из 133137): .md в нативном редакторе (WebDAV load/save + предпросмотр), прогрев WebView для docx/xlsx. **Меню**: иконки шторки в белых круглых рамках с тенью, единый размер. Push/звонки: 30-с таймаут → «Пропущенный звонок» (обезличенно на локскрине) | +NextcloudWebSession (POST /login); CalDAV: PROPPATCH/DELETE/share на календарях, POST outbox (VFREEBUSY), ATTACH в VEVENT; OCS: sharees, cloud/user(email), spreed room list | средний: пишущие операции календаря (шаринг/удаление/free-busy/вложения) проверены компиляцией, на устройстве не гонялись
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26
targetSdk 36
versionCode 135
versionName '0.5.127'
versionCode 152
versionName '0.5.144'
missingDimensionStrategy 'default', 'f7'
multiDexEnabled true
@@ -77,7 +77,31 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
}
override fun newImageLoader(): ImageLoader {
// OkHttp с Basic-auth для хоста сервера: серверные картинки (иконки внешних сайтов
// /apps/external/img, превью и т.п.) требуют логина — без заголовка coil ловит 401.
val authedClient = okhttp3.OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request()
val session = runCatching { AuthStore(this).load() }.getOrNull()
val serverHost = session?.serverUrl?.let {
runCatching { android.net.Uri.parse(it).host }.getOrNull()
}
val patched = if (session != null && serverHost != null && request.url.host == serverHost) {
request.newBuilder()
.header(
"Authorization",
okhttp3.Credentials.basic(session.username, session.appPassword),
)
.header("OCS-APIRequest", "true")
.build()
} else {
request
}
chain.proceed(patched)
}
.build()
return ImageLoader.Builder(this)
.okHttpClient(authedClient)
.components { add(SvgDecoder.Factory()) }
.crossfade(false)
.memoryCache {
@@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NextcloudWebSession
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
class OfficeEditorActivity : ComponentActivity() {
@@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() {
if (current == next) return
editorVisible = false
loadError = null
view.loadUrl(target.url)
startLoad(view, target)
}
/**
* Collabora — грузим сразу (iframe самодостаточен по WOPI-токену). Web-viewer (доска и пр.)
* требует НАСТОЯЩУЮ веб-сессию: сначала логинимся app-паролем, переносим cookie в
* WebView CookieManager, только потом открываем URL — иначе viewer видит анонима («нет файлов»).
*/
private fun startLoad(view: WebView, target: OfficeEditorLaunch) {
if (target.collaboraBaseUrl.isNotBlank()) {
view.loadUrl(target.url)
return
}
lifecycleScope.launch {
val ok = runCatching { establishWebSession(target) }.getOrDefault(false)
if (!ok) {
loadError = "Не удалось открыть сессию. Проверьте интернет и попробуйте снова."
return@launch
}
view.loadUrl(target.url)
}
}
/** Логин по форме `/login`, перенос session-cookie в WebView. Возвращает true при успехе. */
private suspend fun establishWebSession(target: OfficeEditorLaunch): Boolean {
val serverUrl = target.serverUrl.ifBlank { target.url }
val cookies = withContext(Dispatchers.IO) {
NextcloudWebSession.login(
serverUrl = serverUrl,
username = target.username,
appPassword = target.password,
trustAllCerts = target.trustAllCerts,
)
}
if (cookies.isEmpty()) return false
val cm = CookieManager.getInstance()
cm.setAcceptCookie(true)
webViewRef?.let { cm.setAcceptThirdPartyCookies(it, true) }
val base = serverUrl.trimEnd('/')
cookies.forEach { c ->
val attrs = buildString {
append("${c.name}=${c.value}; Path=${c.path}")
if (c.secure) append("; Secure")
if (c.httpOnly) append("; HttpOnly")
}
cm.setCookie(base, attrs)
}
withContext(Dispatchers.IO) { cm.flush() }
return true
}
private fun readLaunch(): OfficeEditorLaunch? {
@@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() {
val current = webView.url?.trimEnd('/').orEmpty()
val target = launch.url.trimEnd('/')
if (current != target) {
webView.loadUrl(launch.url)
startLoad(webView, launch)
}
}
},
@@ -346,7 +399,14 @@ class OfficeEditorActivity : ComponentActivity() {
},
onPageReady = { view ->
runOnUiThread {
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
if (launch.collaboraBaseUrl.isNotBlank()) {
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
} else {
// Web-файлы (whiteboard и пр.): Collabora-моста нет — готовность = загрузка
// страницы; chrome НЕ прячем (иначе снесётся весь контейнер приложения).
editorVisible = true
loadError = null
}
}
},
)
@@ -23,12 +23,17 @@ internal class OfficeWebViewClient(
private val onPageReady: (WebView?) -> Unit,
) : WebViewClient() {
// Скрытие chrome forbion — ТОЛЬКО для Collabora-страницы (там документ в отдельном
// #documents-content). На Files-странице (whiteboard и пр.) этот скрипт сносит #body-user
// = весь контейнер приложения → белый экран. Поэтому для web-файлов не инжектим.
private val hideChrome: Boolean get() = launch.collaboraBaseUrl.isNotBlank()
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
}
override fun onPageFinished(view: WebView?, url: String?) {
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
onPageReady(view)
}
@@ -123,11 +128,10 @@ internal class OfficeWebViewClient(
}
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
if (launch.collaboraBaseUrl.isNotBlank()) {
prefixes += launch.collaboraBaseUrl.trimEnd('/')
}
return prefixes.distinct()
// Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии
// (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files.
if (launch.collaboraBaseUrl.isBlank()) return emptyList()
return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct()
}
}
}
@@ -48,7 +48,7 @@ object AppMenuRepository {
}
AppMenuExternalSite(
name = name,
iconUrl = icon ?: defaultExternalIcon(session.serverUrl, name),
iconUrl = resolveExternalIcon(session.serverUrl, icon, name),
openUrl = openUrl,
)
}
@@ -56,12 +56,18 @@ object AppMenuRepository {
}.getOrDefault(emptyList())
}
private fun defaultExternalIcon(serverUrl: String, name: String): String {
/**
* Иконка внешнего сайта. API external отдаёт `icon` как ИМЯ ФАЙЛА (напр. «bitrix24.png»),
* а не URL — сайт грузит его с `/apps/external/img/{icon}` (см. layout.user.php). Раньше
* имя файла бралось как есть → битая ссылка → глобус-заглушка у Bitrix/1C.
*/
private fun resolveExternalIcon(serverUrl: String, icon: String?, name: String): String {
val base = serverUrl.trimEnd('/')
return when {
name.contains("bitrix", ignoreCase = true) -> "$base/themes/forbion/images/header/bitrix-glass.svg"
name.contains("1c", ignoreCase = true) -> "$base/themes/forbion/images/header/1c-glass.svg"
else -> "$base/index.php/apps/external/img/external.svg"
icon.isNullOrBlank() -> "$base/index.php/apps/external/img/external.svg"
icon.startsWith("http") -> icon
icon.startsWith("/") -> "$base$icon"
else -> "$base/index.php/apps/external/img/$icon"
}
}
}
@@ -2,8 +2,9 @@ package ru.forbion.f7cloud.mobile.ui
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
// Иконки-glass шторки — локальные (assets/menu/, из живой темы forbion): офлайн + точные.
private fun glass(name: String): String = "file:///android_asset/menu/$name"
// Иконки шторки — ПЛОСКИЕ 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,
@@ -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 = glass(entry.iconPath),
iconUrl = menuIcon(entry.iconPath),
selected = entry.tab == active,
externalUrl = entry.webPath?.let { "$base$it" },
)
@@ -72,16 +77,22 @@ fun appMenuItems(
externalUrl = site.openUrl,
)
}
// Нативные пункты — тоже glass-иконки живой темы (mobile-*-glass), действия по label в AppScaffold
// Нативные пункты — плоские menu-иконки темы, действия по label в AppScaffold.
// Порядок как на сайте: после внешних сайтов — Личный кабинет, затем Уведомления, Настройки.
val native = listOf(
F7AppMenuItem(
label = "Личный кабинет",
iconUrl = menuIcon("lk"),
selected = false,
),
F7AppMenuItem(
label = "Уведомления",
iconUrl = glass("mobile-notifications-glass.svg"),
iconUrl = menuIcon("notifications"),
selected = false,
),
F7AppMenuItem(
label = "Настройки",
iconUrl = glass("mobile-settings-glass.svg"),
iconUrl = menuIcon("settings"),
selected = false,
),
)
@@ -134,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) }
@@ -458,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 = {
@@ -467,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 = {
@@ -564,6 +570,7 @@ fun AppScaffold(
modifier = contentModifier,
createRequest = tasksCreateRequest,
openListSlug = pendingTasksListSlug,
showListsRequest = tasksShowListsRequest,
onOpenListConsumed = { pendingTasksListSlug = null },
onUnauthorized = forceLogout,
)
@@ -1,44 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36127)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36127)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36127)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M9.46191 34.6143V16.043H39.5381V34.6143C39.5381 36.2224 38.2345 37.5259 36.6264 37.5259H12.3736C10.7655 37.5259 9.46191 36.2224 9.46191 34.6143Z" fill="white" stroke="black"/>
<path d="M9.46191 16.0426V12.5093C9.46191 10.9013 10.7655 9.59766 12.3736 9.59766H36.6264C38.2345 9.59766 39.5381 10.9012 39.5381 12.5093V16.0426H9.46191Z" fill="white" stroke="black"/>
<path d="M15.1792 7.44922H14.4867C14.0847 7.44922 13.7588 7.77512 13.7588 8.17713V12.092C13.7588 12.4941 14.0847 12.82 14.4867 12.82H15.1792C15.5812 12.82 15.9071 12.4941 15.9071 12.092V8.17713C15.9071 7.77512 15.5812 7.44922 15.1792 7.44922Z" fill="white" stroke="black"/>
<path d="M21.6235 7.44922H20.931C20.529 7.44922 20.2031 7.77512 20.2031 8.17713V12.092C20.2031 12.4941 20.529 12.82 20.931 12.82H21.6235C22.0255 12.82 22.3514 12.4941 22.3514 12.092V8.17713C22.3514 7.77512 22.0255 7.44922 21.6235 7.44922Z" fill="white" stroke="black"/>
<path d="M28.0688 7.44922H27.3764C26.9743 7.44922 26.6484 7.77512 26.6484 8.17713V12.092C26.6484 12.4941 26.9743 12.82 27.3764 12.82H28.0688C28.4708 12.82 28.7967 12.4941 28.7967 12.092V8.17713C28.7967 7.77512 28.4708 7.44922 28.0688 7.44922Z" fill="white" stroke="black"/>
<path d="M34.5132 7.44922H33.8207C33.4187 7.44922 33.0928 7.77512 33.0928 8.17713V12.092C33.0928 12.4941 33.4187 12.82 33.8207 12.82H34.5132C34.9152 12.82 35.2411 12.4941 35.2411 12.092V8.17713C35.2411 7.77512 34.9152 7.44922 34.5132 7.44922Z" fill="white" stroke="black"/>
<path d="M24.0094 29.8013V31.043H19.2809V29.8013H20.9992V23.4547C20.9323 23.5551 20.8194 23.668 20.6605 23.7934C20.51 23.9104 20.3386 24.0275 20.1463 24.1446C19.954 24.2533 19.7533 24.3453 19.5443 24.4205C19.3436 24.4958 19.1596 24.5334 18.9924 24.5334V23.2541C19.2181 23.2541 19.4439 23.1955 19.6697 23.0785C19.9038 22.9614 20.1212 22.8276 20.3219 22.6771C20.5226 22.5182 20.6814 22.3761 20.7985 22.2507C20.9239 22.1252 20.9908 22.0583 20.9992 22.05H22.4165V29.8013H24.0094ZM27.7577 31.1433C27.2643 31.1433 26.8128 31.0722 26.4031 30.9301C26.0017 30.7879 25.663 30.5831 25.3871 30.3155C25.1112 30.0479 24.9105 29.7385 24.7851 29.3874L25.5376 28.4843C25.5962 28.7017 25.7174 28.9191 25.9014 29.1365C26.0937 29.3539 26.3403 29.5295 26.6414 29.6633C26.9507 29.7971 27.3103 29.864 27.72 29.864C28.0796 29.864 28.3973 29.8096 28.6733 29.7009C28.9576 29.5839 29.1833 29.4208 29.3506 29.2118C29.5178 29.0027 29.6014 28.756 29.6014 28.4717C29.6014 28.154 29.5011 27.8864 29.3004 27.669C29.0997 27.4516 28.8112 27.2886 28.4349 27.1799C28.0587 27.0712 27.6071 27.0168 27.0804 27.0168H26.6539V25.9131H27.0804C27.7744 25.9131 28.3262 25.8002 28.736 25.5744C29.1541 25.3487 29.3631 25.0142 29.3631 24.571C29.3631 24.2951 29.2878 24.0568 29.1373 23.8561C28.9868 23.6471 28.7903 23.4924 28.5478 23.392C28.3053 23.2833 28.0378 23.229 27.7451 23.229C27.2685 23.229 26.8546 23.3377 26.5034 23.5551C26.1522 23.7725 25.8972 24.0401 25.7383 24.3578L24.8352 23.3669C25.0108 23.091 25.2533 22.8527 25.5627 22.652C25.8721 22.4513 26.2191 22.2966 26.6037 22.1879C26.9967 22.0792 27.3939 22.0249 27.7953 22.0249C28.3806 22.0249 28.9032 22.1294 29.3631 22.3385C29.823 22.5475 30.1825 22.836 30.4418 23.2039C30.701 23.5634 30.8306 23.9732 30.8306 24.4331C30.8306 24.7675 30.7595 25.0685 30.6173 25.3361C30.4836 25.6037 30.2871 25.8295 30.0278 26.0134C29.777 26.189 29.476 26.3186 29.1248 26.4022C29.5011 26.4691 29.8313 26.6113 30.1156 26.8287C30.4083 27.0461 30.6299 27.3137 30.7804 27.6314C30.9393 27.9491 31.0187 28.2962 31.0187 28.6724C31.0187 29.1909 30.8682 29.634 30.5672 30.0019C30.2745 30.3699 29.8815 30.6542 29.3882 30.8548C28.8948 31.0471 28.3513 31.1433 27.7577 31.1433Z" fill="url(#paint2_linear_2888_36127)" fill-opacity="0.91"/>
<defs>
<filter id="filter0_di_2888_36127" x="0" y="0" width="49" height="49" 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_2888_36127"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36127"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36127" in2="effect1_dropShadow_2888_36127" result="effect2_innerShadow_2888_36127"/>
</filter>
<linearGradient id="paint0_linear_2888_36127" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36127" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36127" x1="18.0547" y1="16.043" x2="35.1" y2="18.8562" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.8 KiB

@@ -1,48 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36139)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36139)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36139)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M13.334 35.4905V11.5096C13.334 9.84638 14.6823 8.49805 16.3456 8.49805H33.6594C35.3227 8.49805 36.671 9.84638 36.671 11.5096V35.4905C36.671 37.1541 35.3224 38.5028 33.6587 38.5028H16.3463C14.6827 38.5028 13.334 37.1541 13.334 35.4905Z" fill="white" stroke="black" stroke-width="1.00411"/>
<path d="M13.334 36.2802V10.7206C13.334 9.49313 14.3291 8.49805 15.5566 8.49805H17.7791V38.5028H15.5566C14.3291 38.5028 13.334 37.5077 13.334 36.2802Z" fill="white" stroke="black" stroke-width="1.00411"/>
<path d="M10.1787 22.5852V23.3016C10.1787 23.7175 10.5159 24.0547 10.9318 24.0547H14.9821C15.398 24.0547 15.7351 23.7175 15.7351 23.3016V22.5852C15.7351 22.1693 15.398 21.8321 14.9821 21.8321H10.9318C10.5159 21.8321 10.1787 22.1693 10.1787 22.5852Z" fill="white" stroke="black" stroke-width="1.00411"/>
<path d="M10.1787 29.2532V29.9696C10.1787 30.3855 10.5159 30.7227 10.9318 30.7227H14.9821C15.398 30.7227 15.7351 30.3855 15.7351 29.9696V29.2532C15.7351 28.8372 15.398 28.5001 14.9821 28.5001H10.9318C10.5159 28.5001 10.1787 28.8372 10.1787 29.2532Z" fill="white" stroke="black" stroke-width="1.00411"/>
<path d="M10 15.9192V16.6356C10 17.0515 10.3372 17.3887 10.7531 17.3887H14.8034C15.2193 17.3887 15.5564 17.0515 15.5564 16.6356V15.9192C15.5564 15.5033 15.2193 15.1661 14.8034 15.1661H10.7531C10.3372 15.1661 10 15.5033 10 15.9192Z" fill="white" stroke="black" stroke-width="1.00411"/>
<circle cx="27.2246" cy="21.21" r="2.29203" fill="url(#paint2_linear_2888_36139)" fill-opacity="0.91"/>
<path d="M27.2247 24.2676C24.6649 24.2676 23.1772 26.2776 22.7615 27.4917C22.6522 27.8111 22.9143 28.0876 23.2518 28.0876H31.1975C31.5351 28.0876 31.7972 27.8111 31.6879 27.4917C31.2722 26.2776 29.7844 24.2676 27.2247 24.2676Z" fill="url(#paint3_linear_2888_36139)" fill-opacity="0.91"/>
<defs>
<filter id="filter0_di_2888_36139" x="0" y="0" width="49" height="49" 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_2888_36139"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36139"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36139" in2="effect1_dropShadow_2888_36139" result="effect2_innerShadow_2888_36139"/>
</filter>
<linearGradient id="paint0_linear_2888_36139" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36139" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36139" x1="24.9326" y1="18.918" x2="30.3325" y2="20.2891" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint3_linear_2888_36139" x1="22.6406" y1="24.2676" x2="31.0235" y2="29.3761" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,52 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36152)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36152)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36152)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M10.0049 34.4152V12.5841C10.0049 10.9153 11.3577 9.5625 13.0265 9.5625H35.9733C37.6421 9.5625 38.9949 10.9153 38.9949 12.5841V34.4152C38.9949 36.0844 37.6417 37.4375 35.9725 37.4375H13.0272C11.358 37.4375 10.0049 36.0844 10.0049 34.4152Z" fill="white" stroke="black"/>
<path d="M12.792 15.1387H18.4224" stroke="url(#paint2_linear_2888_36152)" stroke-opacity="0.91" stroke-linecap="round"/>
<path d="M30.6318 15.1387H36.2623" stroke="url(#paint3_linear_2888_36152)" stroke-opacity="0.91" stroke-linecap="round"/>
<path d="M21.7119 15.1387H27.3423" stroke="url(#paint4_linear_2888_36152)" stroke-opacity="0.91" stroke-linecap="round"/>
<rect x="12.7344" y="31.3613" width="13.495" height="5.69" rx="0.614981" transform="rotate(-90 12.7344 31.3613)" fill="white" stroke="black"/>
<rect x="30.5742" y="31.3613" width="13.495" height="5.69" rx="0.614981" transform="rotate(-90 30.5742 31.3613)" fill="white" stroke="black"/>
<rect x="21.6543" y="23.5586" width="5.69" height="5.69" rx="0.614981" transform="rotate(-90 21.6543 23.5586)" fill="white" stroke="black"/>
<defs>
<filter id="filter0_di_2888_36152" x="0" y="0" width="49" height="49" 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_2888_36152"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36152"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36152" in2="effect1_dropShadow_2888_36152" result="effect2_innerShadow_2888_36152"/>
</filter>
<linearGradient id="paint0_linear_2888_36152" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36152" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36152" x1="12.792" y1="15.1387" x2="15.1114" y2="18.4546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint3_linear_2888_36152" x1="30.6318" y1="15.1387" x2="32.9512" y2="18.4546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint4_linear_2888_36152" x1="21.7119" y1="15.1387" x2="24.0313" y2="18.4546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

@@ -1,17 +0,0 @@
<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_591)">
<circle cx="17.5" cy="17.5" r="17.5" fill="#e5efe8"/>
<path d="M6.125 25.6282V8.49625C6.125 7.18664 7.18664 6.125 8.49625 6.125H26.5037C27.8134 6.125 28.875 7.18664 28.875 8.49625V25.6282C28.875 26.9381 27.8131 28 26.5032 28H8.49683C7.18691 28 6.125 26.9381 6.125 25.6282Z" fill="#F5F5F5" stroke="black" stroke-width="0.790611"/>
<path d="M8.3125 10.5L12.6875 10.5" stroke="#70B62B" stroke-width="0.7875" stroke-linecap="round"/>
<path d="M22.3125 10.5L26.7537 10.5" stroke="#70B62B" stroke-width="0.7875" stroke-linecap="round"/>
<path d="M15.3125 10.5L19.8459 10.5" stroke="#70B62B" stroke-width="0.7875" stroke-linecap="round"/>
<rect x="8.26875" y="23.2313" width="10.5875" height="4.4625" rx="0.48125" transform="rotate(-90 8.26875 23.2313)" stroke="black" stroke-width="0.7875"/>
<rect x="22.2687" y="23.2313" width="10.5875" height="4.4625" rx="0.48125" transform="rotate(-90 22.2687 23.2313)" stroke="black" stroke-width="0.7875"/>
<rect x="15.2687" y="17.1063" width="4.4625" height="4.4625" rx="0.48125" transform="rotate(-90 15.2687 17.1063)" stroke="black" stroke-width="0.7875"/>
</g>
<defs>
<clipPath id="clip0_205_591">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,52 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36114)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36114)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36114)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M38.5625 20.1419V27.3323C38.5625 28.9311 37.2664 30.2272 35.6677 30.2272H12.2519C10.6531 30.2272 9.35707 28.9311 9.35707 27.3323V16.8968C9.35707 15.298 10.6531 14.002 12.2519 14.002H24.3832C25.151 14.002 25.8873 14.3069 26.4302 14.8498L27.9795 16.3991C28.5224 16.942 29.2587 17.247 30.0264 17.247H35.6676C37.2664 17.247 38.5625 18.5431 38.5625 20.1419Z" fill="white" stroke="black"/>
<path d="M13.6836 8.01172V29.7271C13.6836 30.0032 13.9075 30.2271 14.1836 30.2271H34.8172C35.0934 30.2271 35.3172 30.0032 35.3172 29.727V8.01172C35.3172 7.73558 35.0934 7.51172 34.8172 7.51172H14.1836C13.9075 7.51172 13.6836 7.73558 13.6836 8.01172Z" fill="white" stroke="black"/>
<path d="M12.6016 10.1758V32.9728C12.6016 33.2489 12.8254 33.4728 13.1016 33.4728H32.6535C32.9297 33.4728 33.1535 33.2489 33.1535 32.9728V10.1758C33.1535 9.89964 32.9297 9.67578 32.6535 9.67578H13.1016C12.8254 9.67578 12.6016 9.89964 12.6016 10.1758Z" fill="white" stroke="black"/>
<path d="M9.35645 25.552V34.9059C9.35645 36.5046 10.6525 37.8007 12.2513 37.8007H36.7487C38.3475 37.8007 39.6436 36.5046 39.6436 34.9059V22.307C39.6436 20.7082 38.3475 19.4121 36.7487 19.4121H22.0977C21.3387 19.4121 20.61 19.7102 20.0686 20.2423L18.4561 21.827C17.9147 22.359 17.1861 22.6572 16.427 22.6572H12.2513C10.6525 22.6572 9.35645 23.9532 9.35645 25.552Z" fill="white" stroke="black"/>
<path d="M14.7656 12.9219H30.9909" stroke="url(#paint2_linear_2888_36114)" stroke-opacity="0.91" stroke-linecap="round"/>
<path d="M14.7656 15.084H30.9909" stroke="url(#paint3_linear_2888_36114)" stroke-opacity="0.91" stroke-linecap="round"/>
<path d="M14.7656 17.248H30.9909" stroke="url(#paint4_linear_2888_36114)" stroke-opacity="0.91" stroke-linecap="round"/>
<defs>
<filter id="filter0_di_2888_36114" x="0" y="0" width="49" height="49" 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_2888_36114"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36114"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36114" in2="effect1_dropShadow_2888_36114" result="effect2_innerShadow_2888_36114"/>
</filter>
<linearGradient id="paint0_linear_2888_36114" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36114" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36114" x1="14.7656" y1="12.9219" x2="15.8976" y2="17.5854" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint3_linear_2888_36114" x1="14.7656" y1="15.084" x2="15.8976" y2="19.7475" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint4_linear_2888_36114" x1="14.7656" y1="17.248" x2="15.8976" y2="21.9116" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

@@ -1,46 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36103)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36103)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36103)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M8.42773 13.3065V34.6143C8.42773 35.2994 8.83838 35.9178 9.46984 36.1836C9.67894 36.2716 9.90352 36.317 10.1304 36.317H23.3758H31.3029H38.9154C39.1238 36.317 39.331 36.285 39.5298 36.2223L39.6153 36.1953C40.4645 35.9272 41.0418 35.1395 41.0418 34.2491V13.3065C41.0418 12.1794 40.1281 11.2656 39.0009 11.2656H24.7348H10.4686C9.34148 11.2656 8.42773 12.1794 8.42773 13.3065Z" fill="white" stroke="black"/>
<path d="M40.5785 32.6801L26.9358 18.7502C25.7321 17.5211 23.7523 17.5249 22.5533 18.7586L9.28202 32.414C8.73422 32.9777 8.42777 33.7327 8.42777 34.5187V35.0417C8.42777 35.4792 8.65206 35.886 9.02193 36.1196C9.22561 36.2482 9.46157 36.3164 9.70245 36.3164L11.1456 36.3164H16.486L31.7755 36.3164H39.2299C41.0832 36.3164 41.5051 33.9059 40.5785 32.6801Z" stroke="black"/>
<path d="M9.146 14.507L24.064 27.5691C24.4505 27.9076 25.0284 27.9059 25.413 27.5651L40.5352 14.1648C40.8643 13.8732 41.0527 13.4545 41.0527 13.0148C41.0527 12.6817 40.9445 12.3577 40.7444 12.0915L40.5825 11.8761C40.299 11.4989 39.9081 11.216 39.4612 11.0646L38.8141 10.8454C38.4978 10.7383 38.166 10.6836 37.8319 10.6836H32.9989H25.3423H21.2663H17.1904H10.2031C8.47223 10.6836 8.04652 12.7971 8.75386 14.0571C8.85195 14.2318 8.99525 14.375 9.146 14.507Z" fill="white" stroke="black"/>
<path d="M24.5492 15.8516C24.2716 15.8516 24.0158 15.9584 23.7809 16.1738C23.5461 16.3891 23.3683 16.6953 23.2492 17.0923C23.1309 17.4893 23.0706 17.8417 23.0706 18.1487C23.0706 18.5584 23.1536 18.8612 23.3188 19.0546C23.4849 19.2481 23.6904 19.3457 23.9353 19.3457L24.1282 20.1784C23.9839 20.2146 23.8346 20.2322 23.6795 20.2322C23.1444 20.2322 22.7007 20.0472 22.3476 19.6771C22.2377 19.5618 22.1446 19.434 22.0691 19.2944C21.9022 18.9874 21.8184 18.6231 21.8184 18.2009C21.8184 17.4414 22.0314 16.7483 22.4566 16.1225C22.9724 15.3595 23.6325 14.9785 24.4385 14.9785C24.5006 14.9785 24.5609 14.981 24.6188 14.9869L24.5492 15.8508V15.8516Z" fill="url(#paint2_linear_2888_36103)" fill-opacity="0.91"/>
<path d="M27.3354 14.694C26.7617 14.1591 25.9607 13.8916 24.9333 13.8916C24.0602 13.8916 23.3205 14.0708 22.7132 14.4274C22.106 14.784 21.6481 15.2862 21.3403 15.9347C21.0325 16.5824 20.879 17.257 20.879 17.9576C20.879 18.5111 20.9922 19.025 21.2186 19.4986C21.2706 19.6079 21.3294 19.7148 21.3931 19.8199C21.7361 20.3793 22.2184 20.7864 22.8382 21.0429C23.458 21.2995 24.1676 21.4281 24.9668 21.4281C25.7661 21.4281 26.3994 21.3205 26.9529 21.106C27.5073 20.8915 27.9535 20.5685 28.2923 20.1395H29.3214C28.9994 20.7956 28.5054 21.3087 27.8377 21.6813C27.0737 22.1078 26.1427 22.3214 25.044 22.3214C23.9453 22.3214 23.0621 22.1414 22.2897 21.7814C21.5164 21.4206 20.9419 20.8898 20.5645 20.1866C20.1879 19.4826 20 18.7172 20 17.8903C20 16.9819 20.2139 16.1366 20.6425 15.3526C21.071 14.5696 21.6581 13.9808 22.4021 13.588C23.146 13.1968 23.9965 13 24.9534 13C25.7653 13 26.4866 13.159 27.119 13.4752C27.7505 13.7915 28.2328 14.2424 28.5658 14.8253C28.8996 15.4082 29.0665 16.0457 29.0665 16.7372C29.0665 17.5606 28.8132 18.3059 28.3074 18.9712C27.6725 19.8115 26.859 20.2312 25.8668 20.2312C25.6001 20.2312 25.3979 20.185 25.2621 20.0907C25.127 19.9974 25.0365 19.8603 24.992 19.6794C24.7303 19.9326 24.441 20.0983 24.1256 20.1774L23.9327 19.3447C24.1164 19.3447 24.2883 19.2992 24.4494 19.2084C24.5718 19.1445 24.6926 19.0427 24.8117 18.9031C24.9828 18.7062 25.1296 18.4186 25.2545 18.0401C25.3787 17.6624 25.4407 17.3108 25.4407 16.9836C25.4407 16.6194 25.356 16.3385 25.1874 16.1433C25.018 15.9473 24.805 15.8498 24.5475 15.8498L24.6171 14.9859C25.0994 15.028 25.4634 15.245 25.7074 15.637L25.8282 15.0986H27.1039L26.3742 18.5733C26.3297 18.7937 26.3063 18.9359 26.3063 18.9998C26.3063 19.0805 26.3247 19.1411 26.3625 19.1815C26.4002 19.2219 26.4438 19.242 26.495 19.242C26.6493 19.242 26.8497 19.1487 27.0938 18.9611C27.4226 18.7155 27.6885 18.3866 27.8914 17.9728C28.0944 17.5589 28.1959 17.1308 28.1959 16.6884C28.1959 15.9431 27.9434 15.3131 27.4402 14.7958C27.4067 14.7613 27.3714 14.7277 27.3354 14.694Z" fill="url(#paint3_linear_2888_36103)" fill-opacity="0.91"/>
<defs>
<filter id="filter0_di_2888_36103" x="0" y="0" width="49" height="49" 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_2888_36103"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36103"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36103" in2="effect1_dropShadow_2888_36103" result="effect2_innerShadow_2888_36103"/>
</filter>
<linearGradient id="paint0_linear_2888_36103" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36103" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36103" x1="21.8184" y1="14.9785" x2="25.2667" y2="15.4453" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint3_linear_2888_36103" x1="20" y1="13" x2="30.9804" y2="15.7881" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.4 KiB

@@ -1,8 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="17.5" cy="17.5" r="17.5" fill="#e5efe8"/>
<path d="M5 10.0405V26.6132C5 27.1461 5.31939 27.627 5.81053 27.8338C5.97316 27.9022 6.14783 27.9375 6.32429 27.9375H16.6263H22.7918H28.7126C28.8747 27.9375 29.0359 27.9127 29.1905 27.8639L29.257 27.8429C29.9174 27.6344 30.3665 27.0217 30.3665 26.3291V10.0405C30.3665 9.16381 29.6558 8.45312 28.7791 8.45312H17.6832H6.58736C5.71069 8.45312 5 9.16381 5 10.0405Z" fill="#F5F5F5" stroke="black" stroke-width="0.793683"/>
<path d="M30.0062 25.1093L19.3952 14.2749C18.4589 13.3189 16.9191 13.3219 15.9866 14.2814L5.66445 24.9023C5.23838 25.3407 5.00003 25.928 5.00003 26.5393V26.9461C5.00003 27.2863 5.17448 27.6028 5.46215 27.7844C5.62057 27.8844 5.80409 27.9375 5.99144 27.9375H7.1139H11.2675L23.1594 27.9375L28.9572 27.9375C30.3987 27.9375 30.7269 26.0626 30.0062 25.1093Z" stroke="black" stroke-width="0.793683"/>
<path d="M5.55865 10.9737L17.1615 21.1332C17.4622 21.3965 17.9117 21.3951 18.2107 21.1301L29.9725 10.7076C30.2285 10.4808 30.375 10.1551 30.375 9.81312C30.375 9.55409 30.2908 9.30207 30.1352 9.09501L30.0093 8.92751C29.7887 8.6341 29.4848 8.41412 29.1371 8.29636L28.6339 8.12588C28.3878 8.04252 28.1297 8 27.8699 8H24.1109H18.1557H14.9856H11.8154H6.38085C5.0346 8 4.7035 9.64383 5.25366 10.6238C5.32995 10.7597 5.4414 10.8711 5.55865 10.9737Z" fill="#F5F5F5" stroke="black" stroke-width="0.793683"/>
<path d="M17.5381 12.2162C17.3221 12.2162 17.1232 12.2993 16.9405 12.4667C16.7579 12.6342 16.6196 12.8724 16.5269 13.1811C16.435 13.4899 16.388 13.7641 16.388 14.0028C16.388 14.3214 16.4526 14.557 16.5811 14.7074C16.7102 14.8579 16.8701 14.9338 17.0605 14.9338L17.2106 15.5815C17.0984 15.6096 16.9823 15.6233 16.8616 15.6233C16.4454 15.6233 16.1003 15.4794 15.8257 15.1915C15.7402 15.1019 15.6678 15.0025 15.6091 14.8939C15.4793 14.6551 15.4141 14.3718 15.4141 14.0434C15.4141 13.4526 15.5798 12.9136 15.9105 12.4268C16.3117 11.8335 16.8251 11.5371 17.4519 11.5371C17.5002 11.5371 17.5472 11.5391 17.5922 11.5437L17.5381 12.2155V12.2162Z" fill="#70B62B"/>
<path d="M19.7053 11.3176C19.2591 10.9015 18.6361 10.6935 17.837 10.6935C17.1579 10.6935 16.5826 10.8328 16.1103 11.1102C15.638 11.3876 15.2818 11.7782 15.0424 12.2826C14.803 12.7863 14.6836 13.311 14.6836 13.8559C14.6836 14.2864 14.7717 14.6861 14.9478 15.0545C14.9883 15.1395 15.0339 15.2226 15.0835 15.3044C15.3503 15.7394 15.7254 16.0561 16.2075 16.2556C16.6896 16.4551 17.2414 16.5552 17.8631 16.5552C18.4848 16.5552 18.9773 16.4715 19.4078 16.3047C19.839 16.1378 20.186 15.8866 20.4496 15.553H21.25C20.9995 16.0633 20.6153 16.4623 20.096 16.7521C19.5018 17.0838 18.7777 17.25 17.9231 17.25C17.0686 17.25 16.3817 17.11 15.7809 16.83C15.1794 16.5493 14.7326 16.1365 14.439 15.5896C14.1461 15.042 14 14.4467 14 13.8036C14 13.097 14.1663 12.4396 14.4997 11.8298C14.833 11.2208 15.2897 10.7628 15.8683 10.4573C16.4469 10.1531 17.1084 10 17.8527 10C18.4841 10 19.0451 10.1236 19.537 10.3696C20.0282 10.6156 20.4033 10.9663 20.6623 11.4196C20.9219 11.873 21.0517 12.3689 21.0517 12.9067C21.0517 13.5471 20.8547 14.1268 20.4613 14.6443C19.9675 15.2978 19.3348 15.6243 18.5631 15.6243C18.3556 15.6243 18.1984 15.5883 18.0927 15.515C17.9877 15.4424 17.9172 15.3358 17.8827 15.1951C17.6791 15.392 17.4541 15.5209 17.2088 15.5824L17.0588 14.9347C17.2016 14.9347 17.3354 14.8994 17.4606 14.8288C17.5559 14.779 17.6498 14.6999 17.7424 14.5913C17.8755 14.4382 17.9897 14.2144 18.0869 13.9201C18.1834 13.6263 18.2317 13.3528 18.2317 13.0984C18.2317 12.8151 18.1658 12.5966 18.0347 12.4448C17.9029 12.2924 17.7372 12.2165 17.5369 12.2165L17.5911 11.5446C17.9662 11.5773 18.2493 11.7461 18.4391 12.051L18.533 11.6323H19.5252L18.9577 14.3348C18.9231 14.5062 18.9049 14.6168 18.9049 14.6665C18.9049 14.7293 18.9192 14.7764 18.9486 14.8078C18.9779 14.8392 19.0119 14.8549 19.0516 14.8549C19.1717 14.8549 19.3276 14.7823 19.5174 14.6364C19.7731 14.4454 19.9799 14.1896 20.1378 13.8677C20.2956 13.5458 20.3746 13.2128 20.3746 12.8687C20.3746 12.2891 20.1782 11.7991 19.7868 11.3967C19.7607 11.3699 19.7333 11.3438 19.7053 11.3176Z" fill="#70B62B"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

@@ -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

@@ -1,31 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_mobile_lk)">
<circle cx="24.5" cy="23.5" r="22.5" fill="#FBFBFB"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint0_linear_mobile_lk)" stroke-opacity="0.2"/>
</g>
<path d="M21.6484 30.5127L21.4043 30.3672C20.5844 29.8802 19.8801 29.3418 19.2324 28.9043L19.0439 28.7695C18.119 28.0734 17.4972 27.0443 17.3047 25.8936L16.9492 23.7686L16.8799 23.3516H16.4561C15.6199 23.3515 14.9443 22.676 14.9443 21.8398C14.9443 21.2184 15.3169 20.6859 15.8516 20.4541L16.3086 20.2568L16.1133 19.7988C15.5068 18.3768 15.0616 16.8689 14.7939 15.3223V15.3213C14.633 14.3931 14.554 13.4916 14.7578 12.6826L14.8018 12.5215C15.0627 11.6631 15.7059 11.0626 16.3135 11.043L16.4043 11.0488L16.6992 11.0693L16.8594 10.8193C17.3654 10.0292 18.1702 9.48976 19.0498 9.1709C19.9382 8.84842 20.8853 8.73496 21.8906 8.61914L21.8896 8.61816C22.2806 8.57336 22.6678 8.53 23.0557 8.49121L23.0547 8.49023C24.8442 8.31807 26.7407 8.28354 28.501 8.77344C30.2556 9.26222 31.9082 10.3473 32.7705 12.0332C33.4592 13.378 33.6123 15.0327 33.4307 16.6416C33.3107 17.7074 33.054 18.7541 32.7461 19.8057L32.6143 20.2578L33.0576 20.417C33.6407 20.6259 34.0566 21.1833 34.0566 21.8398C34.0566 22.6762 33.3819 23.3516 32.5449 23.3516H32.1211L32.0518 23.7686L31.6963 25.8936C31.491 27.1211 30.7974 28.2101 29.7686 28.9043C29.119 29.3424 28.4178 29.88 27.5977 30.3672L27.3525 30.5127V32.2617L27.7021 32.3721L34.3613 34.4746C36.836 35.2559 38.7321 37.1384 39.5879 39.4785C39.4957 39.5654 39.4051 39.6539 39.3115 39.7393L38.7871 40.2021C38.0881 40.801 37.3502 41.3559 36.5791 41.8633C33.1124 44.1452 28.9623 45.4726 24.501 45.4727C20.3184 45.4727 16.4089 44.3062 13.0801 42.2803L12.4219 41.8633C11.6508 41.3559 10.9129 40.801 10.2139 40.2021L9.68945 39.7393C9.59565 39.6538 9.50443 39.5656 9.41211 39.4785C10.2674 37.1379 12.1653 35.2555 14.6396 34.4746L21.2998 32.3721L21.6484 32.2617V30.5127Z" fill="#F5F5F5" stroke="#151515"/>
<path d="M25.45 32.4766H23.6647C23.5097 32.4766 23.4011 32.6295 23.4523 32.7758L23.8998 34.0557C23.9314 34.1459 24.0165 34.2064 24.1122 34.2064H25.0025C25.0981 34.2064 25.1833 34.1459 25.2149 34.0557L25.6624 32.7758C25.7135 32.6295 25.6049 32.4766 25.45 32.4766Z" fill="#70B62B"/>
<path d="M22.4034 42.3473L23.921 34.2577C23.9409 34.1513 24.0338 34.0742 24.1421 34.0742H24.9757C25.0851 34.0742 25.1787 34.153 25.1974 34.2608L26.5986 42.3528C26.6101 42.4189 26.5914 42.4868 26.5476 42.5377L24.7261 44.6592C24.638 44.7618 24.4799 44.7641 24.3889 44.664L22.4581 42.5401C22.4107 42.4879 22.3905 42.4165 22.4034 42.3473Z" fill="#70B62B"/>
<defs>
<filter id="filter0_di_mobile_lk" x="0" y="0" width="49" height="49" 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"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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"/>
</filter>
<linearGradient id="paint0_linear_mobile_lk" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 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="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

@@ -1,39 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_mobile_notif)">
<circle cx="24.5" cy="23.5" r="22.5" fill="#FBFBFB"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint0_linear_mobile_notif)" stroke-opacity="0.2"/>
</g>
<path d="M25.45 32.4766H23.6647C23.5097 32.4766 23.4011 32.6295 23.4523 32.7758L23.8998 34.0557C23.9314 34.1459 24.0165 34.2064 24.1122 34.2064H25.0025C25.0981 34.2064 25.1833 34.1459 25.2149 34.0557L25.6624 32.7758C25.7135 32.6295 25.6049 32.4766 25.45 32.4766Z" fill="#70B62B"/>
<g clip-path="url(#clip0_mobile_notif)">
<path d="M26.873 34.1191C26.8587 35.4359 26.5062 36.604 25.9668 37.4336C25.413 38.2852 24.7067 38.7265 24 38.7266C23.2932 38.7266 22.586 38.2854 22.0322 37.4336C21.4929 36.604 21.1403 35.4358 21.126 34.1191C21.7482 34.1398 22.4045 34.1546 23.0967 34.1611L24 34.165C25.0307 34.165 25.9866 34.1486 26.873 34.1191Z" fill="#F5F5F5" stroke="#151515"/>
<circle cx="23.9108" cy="10.1384" r="1.91181" fill="white" stroke="#151515"/>
<path d="M23.918 11.918C24.6991 11.9182 25.1174 11.9077 25.8135 12.0244C26.3882 12.1208 26.7767 12.283 27.2783 12.4951V12.4941C30.3861 13.8088 32.5654 16.8863 32.5654 20.4707V26.2158C32.5654 27.4878 33.1606 28.6857 34.1719 29.4561C34.5152 29.7176 34.9215 30.011 35.3926 30.3262C35.5803 30.4517 35.6934 30.6634 35.6934 30.8906V33.3008C35.6932 33.5439 35.5641 33.7652 35.3604 33.8857L35.2686 33.9307C34.2146 34.3548 31.3656 34.9795 23.9092 34.9795C16.4527 34.9795 13.6037 34.3548 12.5498 33.9307C12.2938 33.8277 12.1251 33.5786 12.125 33.3008V30.8906C12.125 30.6633 12.2381 30.4517 12.4258 30.3262C12.8969 30.011 13.3031 29.7176 13.6465 29.4561C14.6577 28.6857 15.2529 27.4878 15.2529 26.2158V20.5225C15.2531 16.3239 17.8426 12.5461 21.9854 12.0283C22.75 11.9328 23.1522 11.9178 23.918 11.918Z" fill="#F5F5F5" stroke="#151515"/>
<path d="M11.0015 18.9519C11.0717 18.9699 11.1419 18.978 11.2113 18.978C11.5917 18.978 11.9382 18.7213 12.0358 18.3356C12.0464 18.2936 13.1533 14.0906 17.2979 11.8968C17.7134 11.6768 17.8722 11.1618 17.6522 10.7463C17.4322 10.3308 16.9171 10.172 16.5016 10.392C15.3903 10.9801 14.3773 11.7409 13.4908 12.6527C12.7843 13.3792 12.1566 14.2028 11.6252 15.0999C10.7167 16.6341 10.3987 17.8655 10.3857 17.9173C10.2702 18.3732 10.5461 18.8364 11.0015 18.9519Z" fill="#70B62B"/>
<path d="M30.425 11.8969C31.3683 12.3961 32.2299 13.041 32.9862 13.8132C33.596 14.4365 34.1405 15.1446 34.6033 15.9177C35.3976 17.2442 35.6829 18.3176 35.6878 18.3376C35.7862 18.7221 36.1323 18.978 36.5119 18.978C36.5813 18.978 36.6515 18.9695 36.7217 18.9519C37.1776 18.8364 37.4531 18.3736 37.3376 17.9176C37.3245 17.8658 37.0066 16.6344 36.098 15.1001C35.5666 14.2026 34.9388 13.3793 34.2323 12.6528C33.3458 11.741 32.3327 10.9802 31.2213 10.392C30.8058 10.172 30.2907 10.3308 30.0707 10.7463C29.8507 11.1618 30.0095 11.6769 30.425 11.8969Z" fill="#70B62B"/>
</g>
<defs>
<filter id="filter0_di_mobile_notif" x="0" y="0" width="49" height="49" 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"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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"/>
</filter>
<linearGradient id="paint0_linear_mobile_notif" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<clipPath id="clip0_mobile_notif">
<rect width="29.25" height="31.5" fill="white" transform="translate(9.375 7.72656)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,29 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_mobile_settings)">
<circle cx="24.5" cy="23.5" r="22.5" fill="#FBFBFB"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint0_linear_mobile_settings)" stroke-opacity="0.2"/>
</g>
<path d="M17.2747 15.3916C17.6719 15.5314 18.0947 15.5836 18.514 15.5447C18.9333 15.5057 19.3392 15.3765 19.7039 15.1659C20.0685 14.9552 20.3833 14.6682 20.6266 14.3245C20.8699 13.9807 21.0359 13.5884 21.1133 13.1745L21.5804 10.6641C23.5009 10.2266 25.4952 10.2266 27.4157 10.6641L27.8857 13.1745C27.963 13.5884 28.1291 13.9807 28.3724 14.3245C28.6156 14.6682 28.9304 14.9552 29.2951 15.1659C29.6597 15.3765 30.0657 15.5057 30.485 15.5447C30.9043 15.5836 31.3271 15.5314 31.7243 15.3916L34.1311 14.545C35.4708 15.9882 36.469 17.7141 37.0517 19.5951L35.1105 21.259C34.7908 21.533 34.5341 21.873 34.3582 22.2556C34.1822 22.6381 34.0911 23.0542 34.0911 23.4753C34.0911 23.8964 34.1822 24.3125 34.3582 24.6951C34.5341 25.0777 34.7908 25.4176 35.1105 25.6917L37.0517 27.3541C36.469 29.2351 35.4708 30.961 34.1311 32.4042L31.7228 31.5576C31.3256 31.4178 30.9028 31.3656 30.4835 31.4046C30.0642 31.4435 29.6583 31.5727 29.2936 31.7834C28.929 31.994 28.6142 32.281 28.3709 32.6247C28.1276 32.9685 27.9616 33.3608 27.8842 33.7747L27.4186 36.2852C25.4981 36.7227 23.5038 36.7227 21.5833 36.2852L21.1133 33.7747C21.0359 33.3608 20.8699 32.9685 20.6266 32.6247C20.3833 32.281 20.0685 31.994 19.7039 31.7834C19.3392 31.5727 18.9333 31.4435 18.514 31.4046C18.0947 31.3656 17.6719 31.4178 17.2747 31.5576L14.8678 32.4042C13.5281 30.961 12.53 29.2351 11.9473 27.3541L13.8885 25.6902C14.2079 25.4162 14.4644 25.0763 14.6402 24.6939C14.816 24.3114 14.907 23.8955 14.907 23.4746C14.907 23.0537 14.816 22.6378 14.6402 22.2554C14.4644 21.8729 14.2079 21.533 13.8885 21.259L11.9473 19.5951C12.5295 17.7142 13.5272 15.9883 14.8664 14.545L17.2747 15.3916ZM24.4995 19.0959C25.6608 19.0959 26.7745 19.5573 27.5957 20.3784C28.4168 21.1996 28.8782 22.3133 28.8782 23.4746C28.8782 24.6359 28.4168 25.7496 27.5957 26.5708C26.7745 27.392 25.6608 27.8533 24.4995 27.8533C23.3382 27.8533 22.2244 27.392 21.4033 26.5708C20.5821 25.7496 20.1208 24.6359 20.1208 23.4746C20.1208 22.3133 20.5821 21.1996 21.4033 20.3784C22.2244 19.5573 23.3382 19.0959 24.4995 19.0959Z" stroke="#151515" stroke-width="1.04493" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<filter id="filter0_di_mobile_settings" x="0" y="0" width="49" height="49" 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"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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"/>
</filter>
<linearGradient id="paint0_linear_mobile_settings" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

@@ -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

@@ -1,51 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36178)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36178)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36178)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M10.5596 34.5319V12.4645C10.5596 10.8596 11.8606 9.55859 13.4655 9.55859H35.5336C37.1385 9.55859 38.4396 10.8596 38.4396 12.4645V34.5319C38.4396 36.1372 37.1382 37.4386 35.5329 37.4386H13.4662C11.8609 37.4386 10.5596 36.1372 10.5596 34.5319Z" fill="white" stroke="black"/>
<path d="M32.3645 20.0973L34.4368 22.0599M26.6445 27.7874L29.2714 27.6498L34.7878 21.8678C34.9257 21.7232 35.0352 21.5515 35.1099 21.3625C35.1846 21.1736 35.223 20.971 35.223 20.7665C35.223 20.562 35.1846 20.3594 35.1099 20.1705C35.0352 19.9815 34.9257 19.8098 34.7878 19.6652C34.6498 19.5205 34.486 19.4058 34.3057 19.3275C34.1254 19.2493 33.9322 19.209 33.737 19.209C33.5419 19.209 33.3487 19.2493 33.1684 19.3275C32.9881 19.4058 32.8243 19.5205 32.6863 19.6652L27.1699 25.4471L26.6445 27.7874Z" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.7773 22.4258H26.645" stroke="url(#paint2_linear_2888_36178)" stroke-opacity="0.91" stroke-linecap="round"/>
<path d="M13.7773 26.7148H24.5004" stroke="url(#paint3_linear_2888_36178)" stroke-opacity="0.91" stroke-linecap="round"/>
<path d="M13.7773 31.0039H26.645" stroke="url(#paint4_linear_2888_36178)" stroke-opacity="0.91" stroke-linecap="round"/>
<path d="M10.5596 15.9924V12.4653C10.5596 10.86 11.8609 9.55859 13.4662 9.55859H35.5329C37.1382 9.55859 38.4396 10.86 38.4396 12.4653V15.9924H10.5596Z" fill="white" stroke="black" stroke-width="0.968889"/>
<defs>
<filter id="filter0_di_2888_36178" x="0" y="0" width="49" height="49" 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_2888_36178"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36178"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36178" in2="effect1_dropShadow_2888_36178" result="effect2_innerShadow_2888_36178"/>
</filter>
<linearGradient id="paint0_linear_2888_36178" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36178" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36178" x1="13.7773" y1="22.4258" x2="15.1593" y2="26.9411" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint3_linear_2888_36178" x1="13.7773" y1="26.7148" x2="15.3755" y2="31.0662" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint4_linear_2888_36178" x1="13.7773" y1="31.0039" x2="15.1593" y2="35.5192" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

@@ -1,5 +0,0 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="4" cy="4" r="4" fill="#B62B2B"/>
<path d="M4.05859 1.91016V4.44118" stroke="white" stroke-width="0.86956" stroke-linecap="round"/>
<circle cx="4.05833" cy="5.92942" r="0.511452" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 304 B

@@ -1,40 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36093)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36093)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36093)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M22.5421 31.5021C25.4539 31.5021 28.1032 30.3783 30.08 28.5408C32.2554 26.5187 33.6163 23.6322 33.6163 20.4278C33.6163 14.3115 28.6583 9.35352 22.5421 9.35352C16.4258 9.35352 11.4678 14.3115 11.4678 20.4278C11.4678 26.5441 16.4258 31.5021 22.5421 31.5021Z" fill="white" stroke="black" stroke-width="1.11673" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M34.7117 37.4755L28.4082 30.2022L30.6417 27.9688L37.2753 35.5501C37.8919 36.2547 37.7206 37.3456 36.9177 37.8273C36.1945 38.2612 35.2641 38.1128 34.7117 37.4755Z" fill="white" stroke="black" stroke-width="1.11673"/>
<path d="M27.85 17.36C27.1173 16.2965 26.247 15.4531 25.2888 14.8782C24.3306 14.3033 23.3034 14.0082 22.2663 14.0098C21.2292 14.0082 20.202 14.3033 19.2438 14.8782C18.2856 15.4531 17.4152 16.2965 16.6826 17.36" stroke="url(#paint2_linear_2888_36093)" stroke-opacity="0.91" stroke-width="1.6751" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<filter id="filter0_di_2888_36093" x="0" y="0" width="49" height="49" 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_2888_36093"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36093"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36093" in2="effect1_dropShadow_2888_36093" result="effect2_innerShadow_2888_36093"/>
</filter>
<linearGradient id="paint0_linear_2888_36093" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36093" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36093" x1="16.6826" y1="14.0098" x2="24.841" y2="20.915" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

@@ -1,42 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36209)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36209)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36209)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M22.2695 33.4517V32.061C22.2695 31.3434 22.8513 30.7617 23.5688 30.7617H25.4328C26.1504 30.7617 26.7321 31.3434 26.7321 32.061V33.4517C26.7321 34.1693 26.1504 34.751 25.4328 34.751H23.5688C22.8513 34.751 22.2695 34.1693 22.2695 33.4517Z" fill="white" stroke="black"/>
<path d="M16.2301 27.8098C18.4045 29.9843 21.2222 31.1235 24.0706 31.2275C27.2051 31.342 30.377 30.2027 32.7699 27.8098C37.3374 23.2424 37.3374 15.8374 32.7699 11.2699C28.2025 6.7025 20.7975 6.7025 16.2301 11.2699C11.6626 15.8374 11.6626 23.2424 16.2301 27.8098Z" fill="white" stroke="black" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.7786 23.2638C21.7571 24.2423 23.025 24.7549 24.3068 24.8017C25.7173 24.8532 27.1446 24.3406 28.2214 23.2638C30.2767 21.2085 30.2767 17.8763 28.2214 15.821C26.1661 13.7657 22.8339 13.7657 20.7786 15.821C18.7233 17.8763 18.7233 21.2085 20.7786 23.2638Z" fill="white" stroke="black" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23.7287 12.1655C23.9315 12.3683 24.1943 12.4746 24.4599 12.4843C24.7523 12.4949 25.0481 12.3887 25.2713 12.1655C25.6973 11.7395 25.6973 11.0489 25.2713 10.6229C24.8453 10.1969 24.1547 10.1969 23.7287 10.6229C23.3027 11.0489 23.3027 11.7395 23.7287 12.1655Z" fill="url(#paint2_linear_2888_36209)" fill-opacity="0.91"/>
<path d="M16.8008 37.0142V35.6235C16.8008 34.9059 17.3825 34.3242 18.1001 34.3242H30.9003C31.6179 34.3242 32.1996 34.9059 32.1996 35.6235V37.0142C32.1996 37.7318 31.6179 38.3135 30.9003 38.3135H18.1001C17.3825 38.3135 16.8008 37.7318 16.8008 37.0142Z" fill="white" stroke="black"/>
<defs>
<filter id="filter0_di_2888_36209" x="0" y="0" width="49" height="49" 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_2888_36209"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36209"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36209" in2="effect1_dropShadow_2888_36209" result="effect2_innerShadow_2888_36209"/>
</filter>
<linearGradient id="paint0_linear_2888_36209" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36209" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36209" x1="24.5" y1="9.85156" x2="25.8558" y2="12.1301" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

@@ -1,15 +0,0 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1200_28545)">
<circle cx="15" cy="15" r="15" fill="#e5efe8"/>
<path d="M13.5283 21.8039V20.8864C13.5283 20.4131 13.9121 20.0293 14.3855 20.0293H15.6151C16.0885 20.0293 16.4722 20.4131 16.4722 20.8864V21.8039C16.4722 22.2772 16.0885 22.661 15.6151 22.661H14.3855C13.9121 22.661 13.5283 22.2772 13.5283 21.8039Z" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
<path d="M9.54439 18.0817C10.9789 19.5161 12.8376 20.2677 14.7167 20.3363C16.7846 20.4118 18.877 19.6603 20.4556 18.0817C23.4687 15.0686 23.4687 10.1835 20.4556 7.17045C17.4425 4.15735 12.5575 4.15735 9.54439 7.17045C6.5313 10.1835 6.5313 15.0686 9.54439 18.0817Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.545 15.0817C13.1905 15.7272 14.027 16.0654 14.8725 16.0963C15.803 16.1303 16.7446 15.7921 17.455 15.0817C18.8108 13.7258 18.8108 11.5276 17.455 10.1718C16.0991 8.8159 13.9009 8.8159 12.545 10.1718C11.1892 11.5276 11.1892 13.7258 12.545 15.0817Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.3769 7.87512C14.5407 8.03895 14.753 8.12479 14.9676 8.13262C15.2038 8.14125 15.4428 8.05541 15.6231 7.87512C15.9672 7.53099 15.9672 6.97307 15.6231 6.62895C15.279 6.28482 14.721 6.28482 14.3769 6.62895C14.0328 6.97307 14.0328 7.53099 14.3769 7.87512Z" fill="#70B62B"/>
<path d="M9.9209 24.1535V23.236C9.9209 22.7627 10.3047 22.3789 10.778 22.3789H19.2222C19.6956 22.3789 20.0794 22.7627 20.0794 23.236V24.1535C20.0794 24.6268 19.6956 25.0106 19.2222 25.0106H10.778C10.3047 25.0106 9.9209 24.6268 9.9209 24.1535Z" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
</g>
<defs>
<clipPath id="clip0_1200_28545">
<rect width="30" height="30" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,58 +0,0 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_di_2888_36164)">
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36164)" fill-opacity="0.6" shape-rendering="crispEdges"/>
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36164)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
</g>
<path d="M10.5596 34.5319V12.4645C10.5596 10.8596 11.8606 9.55859 13.4655 9.55859H35.5336C37.1385 9.55859 38.4396 10.8596 38.4396 12.4645V34.5319C38.4396 36.1372 37.1382 37.4386 35.5329 37.4386H13.4662C11.8609 37.4386 10.5596 36.1372 10.5596 34.5319Z" fill="white" stroke="black"/>
<path d="M10.5596 17.6009V12.4653C10.5596 10.86 11.8609 9.55859 13.4662 9.55859H35.5329C37.1382 9.55859 38.4396 10.86 38.4396 12.4653V17.6009H10.5596Z" fill="white" stroke="black"/>
<circle cx="13.5093" cy="13.5796" r="0.804231" fill="url(#paint2_linear_2888_36164)" fill-opacity="0.91"/>
<circle cx="15.8687" cy="13.5796" r="0.804231" fill="url(#paint3_linear_2888_36164)" fill-opacity="0.91"/>
<circle cx="18.229" cy="13.5796" r="0.804231" fill="url(#paint4_linear_2888_36164)" fill-opacity="0.91"/>
<path d="M21.2832 13.5801H35.9132" stroke="url(#paint5_linear_2888_36164)" stroke-opacity="0.91" stroke-linecap="round"/>
<rect x="13.2051" y="20.7832" width="9.72308" height="3.28923" rx="0.572308" stroke="black"/>
<rect x="13.2051" y="26.1445" width="15.0846" height="3.28923" rx="0.572308" stroke="black"/>
<rect x="18.5664" y="31.5078" width="17.2292" height="3.28923" rx="0.572308" stroke="black"/>
<defs>
<filter id="filter0_di_2888_36164" x="0" y="0" width="49" height="49" 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_2888_36164"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
<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_2888_36164"/>
<feBlend mode="normal" in="effect2_innerShadow_2888_36164" in2="effect1_dropShadow_2888_36164" result="effect2_innerShadow_2888_36164"/>
</filter>
<linearGradient id="paint0_linear_2888_36164" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.453125" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2888_36164" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.65625" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint2_linear_2888_36164" x1="12.7051" y1="12.7754" x2="14.5998" y2="13.2565" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint3_linear_2888_36164" x1="15.0645" y1="12.7754" x2="16.9592" y2="13.2565" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint4_linear_2888_36164" x1="17.4248" y1="12.7754" x2="19.3195" y2="13.2565" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
<linearGradient id="paint5_linear_2888_36164" x1="21.2832" y1="13.5801" x2="22.5227" y2="18.1847" gradientUnits="userSpaceOnUse">
<stop stop-color="#C0FF7B"/>
<stop offset="0.453125" stop-color="#70B62B"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

@@ -236,31 +236,34 @@ private fun F7AppMenuGridItem(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val localIcon = item.localIcon
if (localIcon != null) {
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
Box(
modifier = Modifier
.size(MenuIconSize)
.clip(CircleShape)
.background(Color.White)
.border(1.5.dp, F7Colors.Green30, CircleShape),
contentAlignment = Alignment.Center,
) {
// Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
// как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
val frameShape = CircleShape
Box(
modifier = Modifier
.size(MenuIconSize)
.shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
.clip(frameShape)
.background(Color.White)
.border(1.dp, Color(0xFFECECEC), frameShape),
contentAlignment = Alignment.Center,
) {
val localIcon = item.localIcon
if (localIcon != null) {
Icon(
localIcon,
contentDescription = item.label,
tint = F7Colors.Primary,
modifier = Modifier.size(MenuIconSize * 0.46f),
modifier = Modifier.size(MenuIconSize * 0.5f),
)
} else {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize * 0.56f),
contentScale = ContentScale.Fit,
)
}
} else {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize),
contentScale = ContentScale.Fit,
)
}
Text(
text = item.label,
@@ -4,7 +4,7 @@ enum class F7BottomBarSlot {
Chats,
NavBack,
SectionMail,
SectionCards,
SectionTasks,
SectionConferences,
Menu,
}
@@ -15,10 +15,8 @@ data class F7BottomBarConfig(
val buttonCount: Int get() = slots.size
companion object {
// Единая навигационная панель по дизайну «Новое меню»:
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
// Нижняя панель 1:1 с мобильным сайтом forbion (layout.user.php):
// «назад», затем ярлыки-разделы Почта · Задачи · Конференции, справа — меню.
@Suppress("UNUSED_PARAMETER")
fun forContext(
tabKey: String,
@@ -27,7 +25,7 @@ data class F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.SectionMail,
F7BottomBarSlot.SectionCards,
F7BottomBarSlot.SectionTasks,
F7BottomBarSlot.SectionConferences,
F7BottomBarSlot.Menu,
),
@@ -30,19 +30,24 @@ data class F7BottomBarActions(
val onChatsClick: () -> Unit = {},
val onNavBackClick: () -> Unit = {},
val onMailClick: () -> Unit = {},
val onCardsClick: () -> Unit = {},
val onTasksClick: () -> Unit = {},
val onConferencesClick: () -> Unit = {},
val onMenuClick: () -> Unit = {},
)
// Иконки нижней панели — ЛОКАЛЬНЫЕ mobile-*-icon из живой темы (assets/menu/), 1:1 с сайтом.
private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
// Живой forbion (_header.css .header__mobile-bottom): пилюля 328×61, паддинг 3,
// фон #F5F5F5; кнопки 55, фон #FBFBFB, рамка #E6E6E6; активная кнопка-раздел и открытый
// бургер — полупрозрачный зелёный градиент 104°.
private val BottomBarWidth = 328.dp
private val BottomBarHeight = 61.dp
private val BottomBarButtonSize = 55.dp
private val BottomBarIconSize = 34.dp
private val BottomBarSectionIconSize = 46.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 BottomBarButtonBg = Color(0xFFFBFBFB)
@@ -94,38 +99,35 @@ fun F7MobileBottomBar(
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,
active = activeTabKey.equals("deck", ignoreCase = true),
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,
@@ -25,6 +25,10 @@ data class DavCalendar(
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
val ctag: String = "",
// supported-calendar-component-set: поддерживает ли коллекция VEVENT. VTODO-only списки
// задач не принимают события → PUT VEVENT туда возвращает 403. По умолчанию true (если
// сервер не вернул свойство — не ломаем поведение).
val supportsEvents: Boolean = true,
)
data class DavEvent(
@@ -50,6 +54,7 @@ data class DavEvent(
val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
)
data class DavTrashEvent(
@@ -75,6 +80,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
@@ -87,6 +94,11 @@ object CalDavClient {
private val icsDue = Pattern.compile("DUE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsStatus = Pattern.compile("STATUS:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsUid = Pattern.compile("UID:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.
private val icsRelatedTo = Pattern.compile(
"RELATED-TO(?:;[^:\\r\\n]*)?:([^\\r\\n]+)",
Pattern.CASE_INSENSITIVE,
)
private val icsDtStamp = Pattern.compile("DTSTAMP[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsLastModified = Pattern.compile("LAST-MODIFIED[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
private val icsPriority = Pattern.compile("PRIORITY:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
@@ -105,8 +117,8 @@ object CalDavClient {
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, baseUrl, depth = 1, body)
@@ -291,11 +303,27 @@ object CalDavClient {
}
client.newCall(builder.build()).execute().use { response ->
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV put event HTTP ${response.code}")
error("CalDAV put event HTTP ${response.code}${davErrorDetail(response)}")
}
}
}
/** Из тела sabre-ошибки достаём человекочитаемую причину (precondition/сообщение). */
private fun davErrorDetail(response: okhttp3.Response): String {
val body = runCatching { response.peekBody(4096).string() }.getOrNull().orEmpty()
if (body.isBlank()) return ""
// sabre: <s:message>…</s:message> и имя нарушенного precondition-элемента.
val message = Regex("<s:message>(.*?)</s:message>", RegexOption.DOT_MATCHES_ALL)
.find(body)?.groupValues?.getOrNull(1)?.trim()
val precondition = Regex("<cal:(supported-calendar-component-set|valid-calendar-object-resource|calendar-collection-location-ok)")
.find(body)?.groupValues?.getOrNull(1)
?: Regex("<d:(need-privileges)").find(body)?.groupValues?.getOrNull(1)
return buildString {
if (precondition != null) append(" ($precondition)")
if (!message.isNullOrBlank()) append(": ${message.take(180)}")
}
}
fun createCalendar(
client: OkHttpClient,
baseUrl: String,
@@ -332,6 +360,122 @@ object CalDavClient {
return DavCalendar(href = url, displayName = displayName)
}
/** PROPPATCH: переименование и/или смена цвета календаря (null = не менять). */
fun updateCalendarProps(
client: OkHttpClient,
calendarHref: String,
displayName: String? = null,
color: String? = null,
) {
if (displayName == null && color == null) return
val props = buildString {
if (displayName != null) append("<d:displayname>${escapeXml(displayName)}</d:displayname>")
if (color != null) append("<ical:calendar-color>${escapeXml(color)}</ical:calendar-color>")
}
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:ical="http://apple.com/ns/ical/">
<d:set><d:prop>$props</d:prop></d:set>
</d:propertyupdate>
""".trimIndent()
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV proppatch HTTP ${response.code}")
}
}
fun deleteCalendar(client: OkHttpClient, calendarHref: String) {
val req = Request.Builder().url(calendarHref.trimEnd('/') + "/").delete().build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete calendar HTTP ${response.code}")
}
}
}
data class DavSharee(
val principal: String, // principal:principals/users/<id> или groups/<id>
val displayName: String,
val writable: Boolean,
)
/** Текущие получатели шаринга календаря (oc:invite). */
fun listCalendarShares(client: OkHttpClient, calendarHref: String): List<DavSharee> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop><oc:invite/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, calendarHref.trimEnd('/') + "/", depth = 0, body)
val out = mutableListOf<DavSharee>()
val parser = newParser(xml)
var principal = ""
var name = ""
var writable = false
var inUser = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"user" -> { inUser = true; principal = ""; name = ""; writable = false }
"href" -> if (inUser) principal = parser.readText().trim()
"common-name" -> if (inUser) name = parser.readText().trim()
"read-write" -> if (inUser) writable = true
}
XmlPullParser.END_TAG -> if (parser.localTag() == "user" && inUser) {
if (principal.isNotBlank()) {
out += DavSharee(
principal = principal,
displayName = name.ifBlank { principal.substringAfterLast('/') },
writable = writable,
)
}
inUser = false
}
}
parser.next()
}
return out
}
/** Пошарить календарь пользователю/группе (sabre sharing POST). */
fun shareCalendar(client: OkHttpClient, calendarHref: String, principal: String, writable: Boolean) {
val rw = if (writable) "<o:read-write/>" else ""
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:set>
<d:href>${escapeXml(principal)}</d:href>
<o:summary/>$rw
</o:set>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
fun unshareCalendar(client: OkHttpClient, calendarHref: String, principal: String) {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:remove><d:href>${escapeXml(principal)}</d:href></o:remove>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
private fun sharePost(client: OkHttpClient, calendarHref: String, body: String) {
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.post(body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV share HTTP ${response.code}")
}
}
fun createSubscription(
client: OkHttpClient,
baseUrl: String,
@@ -586,6 +730,7 @@ object CalDavClient {
organizerName = parsed.organizerName,
alarms = parsed.alarms,
conferenceUri = parsed.conferenceUri,
attachments = parsed.attachments,
)
}
@@ -634,6 +779,7 @@ object CalDavClient {
dueDate: LocalDate? = null,
priority: Int = 0,
description: String = "",
parentUid: String = "",
): DavTask {
val uid = "${UUID.randomUUID()}@f7cloud.mobile"
val ics = buildVTodoIcs(
@@ -644,6 +790,7 @@ object CalDavClient {
priority = priority,
percentComplete = 0,
description = description,
parentUid = parentUid,
)
val url = calendar.href.trimEnd('/') + "/$uid.ics"
val req = Request.Builder()
@@ -670,6 +817,7 @@ object CalDavClient {
description = description,
calendarName = calendar.displayName,
calendarHref = calendar.href,
parentUid = parentUid,
)
}
@@ -691,6 +839,7 @@ object CalDavClient {
priority = priority,
percentComplete = percentComplete,
description = description,
parentUid = task.parentUid, // не терять связь с родителем при правке подзадачи
)
val req = Request.Builder()
.url(task.href)
@@ -788,6 +937,99 @@ object CalDavClient {
.format(instant)
}
/** Занятый интервал участника (free/busy), millis UTC. */
data class BusyPeriod(val startEpochMilli: Long, val endEpochMilli: Long)
/**
* Запрос занятости участников через scheduling outbox (iTIP VFREEBUSY REQUEST).
* Возвращает email занятые интервалы; участник без данных пустой список.
*/
fun freeBusy(
client: OkHttpClient,
serverUrl: String,
userId: String,
organizerEmail: String,
attendeeEmails: List<String>,
rangeStart: Instant,
rangeEnd: Instant,
): Map<String, List<BusyPeriod>> {
if (organizerEmail.isBlank() || attendeeEmails.isEmpty()) return emptyMap()
val outbox = calendarsBase(serverUrl, userId).trimEnd('/') + "/outbox/"
val ics = buildString {
appendLine("BEGIN:VCALENDAR")
appendLine("VERSION:2.0")
appendLine("PRODID:-//F7cloud Mobile//EN")
appendLine("METHOD:REQUEST")
appendLine("BEGIN:VFREEBUSY")
appendLine("UID:${UUID.randomUUID()}@f7cloud.mobile")
appendLine("DTSTAMP:${formatCalDavTime(Instant.now())}")
appendLine("DTSTART:${formatCalDavTime(rangeStart)}")
appendLine("DTEND:${formatCalDavTime(rangeEnd)}")
appendLine("ORGANIZER:mailto:$organizerEmail")
attendeeEmails.forEach { appendLine("ATTENDEE:mailto:$it") }
appendLine("END:VFREEBUSY")
appendLine("END:VCALENDAR")
}
val req = Request.Builder()
.url(outbox)
.post(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build()
val xml = client.newCall(req).execute().use { response ->
if (!response.isSuccessful) error("CalDAV free-busy HTTP ${response.code}")
response.body?.string().orEmpty()
}
return parseFreeBusyResponse(xml)
}
private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>> {
val out = mutableMapOf<String, List<BusyPeriod>>()
val parser = newParser(xml)
var recipient = ""
var calendarData = StringBuilder()
var inCalendarData = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"response" -> { recipient = ""; calendarData = StringBuilder() }
"href" -> if (recipient.isBlank()) recipient = parser.readText().trim()
"calendar-data" -> { inCalendarData = true; calendarData = StringBuilder() }
}
XmlPullParser.TEXT -> if (inCalendarData) calendarData.append(parser.text)
XmlPullParser.END_TAG -> when (parser.localTag()) {
"calendar-data" -> inCalendarData = false
"response" -> if (recipient.isNotBlank()) {
val email = recipient.removePrefix("mailto:").trim()
out[email] = parseBusyPeriods(calendarData.toString())
}
}
}
parser.next()
}
return out
}
private val freeBusyLine = Pattern.compile("^FREEBUSY[^:]*:(.+)$", Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
private fun parseBusyPeriods(ics: String): List<BusyPeriod> {
val unfolded = ics.replace(Regex("\\r?\\n[ \\t]"), "")
val out = mutableListOf<BusyPeriod>()
val m = freeBusyLine.matcher(unfolded)
while (m.find()) {
m.group(1).orEmpty().split(',').forEach { period ->
val parts = period.trim().split('/')
if (parts.size != 2) return@forEach
val start = CalendarIcs.parseIcsInstant(parts[0]) ?: return@forEach
val end = if (parts[1].startsWith("P", ignoreCase = true)) {
runCatching { start.plus(java.time.Duration.parse(parts[1])) }.getOrNull() ?: return@forEach
} else {
CalendarIcs.parseIcsInstant(parts[1]) ?: return@forEach
}
out += BusyPeriod(start.toEpochMilli(), end.toEpochMilli())
}
}
return out.sortedBy { it.startEpochMilli }
}
private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> {
val parser = newParser(xml)
val out = mutableListOf<DavCalendar>()
@@ -798,6 +1040,7 @@ object CalDavClient {
var calendarColor: String? = null
var ctag = ""
var isCollection = false
var comps = mutableSetOf<String>()
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
@@ -808,8 +1051,12 @@ object CalDavClient {
calendarColor = null
ctag = ""
isCollection = false
comps = mutableSetOf()
}
"collection" -> if (inResponse) isCollection = true
"comp" -> if (inResponse) {
parser.getAttributeValue(null, "name")?.trim()?.uppercase()?.let { comps += it }
}
"displayname" -> if (inResponse) displayName = parser.readText().trim()
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
"getctag" -> if (inResponse) ctag = parser.readText().trim()
@@ -826,7 +1073,15 @@ object CalDavClient {
val name = displayName.ifBlank {
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
}
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
// Свойство не вернулось (comps пуст) → считаем событийным (не ломаем).
val supportsEvents = comps.isEmpty() || "VEVENT" in comps
out += DavCalendar(
href = full,
displayName = name,
color = calendarColor,
ctag = ctag,
supportsEvents = supportsEvents,
)
}
}
inResponse = false
@@ -938,6 +1193,7 @@ object CalDavClient {
description = extractIcs(icsDescription, ics),
calendarName = calendarName,
calendarHref = calendarHref,
parentUid = extractIcs(icsRelatedTo, ics).trim(),
)
}
@@ -949,6 +1205,7 @@ object CalDavClient {
priority: Int,
percentComplete: Int,
description: String,
parentUid: String = "",
): String {
val now = Instant.now()
return buildString {
@@ -976,6 +1233,9 @@ object CalDavClient {
if (description.isNotBlank()) {
appendLine("DESCRIPTION:${escapeIcsText(description)}")
}
if (parentUid.isNotBlank()) {
appendLine("RELATED-TO;RELTYPE=PARENT:${escapeIcsText(parentUid)}")
}
appendLine("END:VTODO")
appendLine("END:VCALENDAR")
}
@@ -23,6 +23,12 @@ data class CalendarAlarmData(
val action: String = "DISPLAY",
)
data class CalendarAttachmentData(
val url: String,
val fileName: String = "",
val mimeType: String = "",
)
data class CalendarEventData(
val uid: String,
val summary: String,
@@ -40,6 +46,7 @@ data class CalendarEventData(
val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
)
object CalendarIcs {
@@ -99,6 +106,13 @@ object CalendarIcs {
val talkUrl = conference.ifBlank {
if (location.contains("/call/")) location else ""
}
val attachments = props.filter { it.name == "ATTACH" && it.value.isNotBlank() }.map {
CalendarAttachmentData(
url = it.value.trim(),
fileName = it.params["FILENAME"]?.let(::unescape).orEmpty(),
mimeType = it.params["FMTTYPE"].orEmpty(),
)
}
return CalendarEventData(
uid = uid,
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
@@ -116,6 +130,7 @@ object CalendarIcs {
organizerName = orgName,
alarms = alarms,
conferenceUri = talkUrl,
attachments = attachments,
)
}
@@ -163,6 +178,13 @@ object CalendarIcs {
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
if (data.location.isBlank()) appendLine("LOCATION:$talk")
}
data.attachments.forEach { att ->
if (att.url.isNotBlank()) {
val fmt = if (att.mimeType.isNotBlank()) ";FMTTYPE=${att.mimeType}" else ""
val fname = if (att.fileName.isNotBlank()) ";FILENAME=${escape(att.fileName)}" else ""
appendLine("ATTACH$fmt$fname:${att.url}")
}
}
data.alarms.forEach { alarm ->
appendLine("BEGIN:VALARM")
appendLine("ACTION:${alarm.action}")
@@ -33,6 +33,14 @@ object NetworkFactory {
clients.clear() // пересобрать производные клиенты уже с кэшем
}
/** Билдер на основе базового клиента (пул/кэш) c опциональным trust-all TLS, без auth. */
fun newBaseBuilder(trustAllCerts: Boolean = false): OkHttpClient.Builder =
base.newBuilder()
.callTimeout(30, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.applyUnsafeSslIfNeeded(trustAllCerts)
fun newAuthedClient(
username: String,
appPassword: String,
@@ -0,0 +1,82 @@
package ru.forbion.f7cloud.core.network
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
/**
* Устанавливает НАСТОЯЩУЮ веб-сессию Nextcloud (cookie + CSRF) по форме `/login`, используя
* app-пароль как пароль (он валиден для входа и обходит 2FA). Нужно для встраивания веб-viewer'ов
* (доска Whiteboard и т.п.), которым мало Basic-auth: их Vue-XHR ходят по сессии.
*/
object NextcloudWebSession {
private val requestTokenRegex = Regex(
"""(?:name=["']requesttoken["']\s+value|<meta\s+name=["']requesttoken["']\s+content)=["']([^"']+)["']""",
RegexOption.IGNORE_CASE,
)
/** Cookie, собранные при логине (для переноса в WebView CookieManager). */
fun login(
serverUrl: String,
username: String,
appPassword: String,
trustAllCerts: Boolean = false,
): List<Cookie> {
val base = serverUrl.trimEnd('/')
val host = base.toHttpUrl().host
val jar = MemoryCookieJar()
val client = NetworkFactory.newBaseBuilder(trustAllCerts)
.cookieJar(jar)
.followRedirects(true)
.followSslRedirects(true)
.build()
// 1. GET страницы логина — начальные cookie + requesttoken.
val loginHtml = client.newCall(
Request.Builder().url("$base/login").header("User-Agent", USER_AGENT).get().build(),
).execute().use { it.body?.string().orEmpty() }
val token = requestTokenRegex.find(loginHtml)?.groupValues?.getOrNull(1).orEmpty()
// 2. POST формы логина app-паролем — сервер ставит session-cookie.
val form = FormBody.Builder()
.add("user", username)
.add("password", appPassword)
.apply { if (token.isNotBlank()) add("requesttoken", token) }
.add("remember_login", "1")
.add("timezone", "UTC")
.add("timezone_offset", "0")
.build()
client.newCall(
Request.Builder()
.url("$base/login")
.header("User-Agent", USER_AGENT)
.apply { if (token.isNotBlank()) header("requesttoken", token) }
.header("OCS-APIRequest", "true")
.post(form)
.build(),
).execute().use { /* cookie осели в jar */ }
return jar.cookiesForHost(host)
}
private const val USER_AGENT = "Mozilla/5.0 (Android) F7cloud-Mobile"
private class MemoryCookieJar : CookieJar {
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { c ->
list.removeAll { it.name == c.name }
list += c
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
store[url.host].orEmpty().filter { it.matches(url) }
fun cookiesForHost(host: String): List<Cookie> = store[host].orEmpty().toList()
}
}
+1
View File
@@ -32,6 +32,7 @@ dependencies {
implementation composeBom
implementation libs.compose.ui
implementation libs.compose.material3
implementation libs.compose.material.icons.extended
implementation libs.compose.foundation
implementation libs.lifecycle.viewmodel.compose
implementation libs.activity.compose
@@ -25,6 +25,19 @@ data class CalendarLocationSuggestion(
val address: String,
)
data class CalendarTalkRoom(
val token: String,
val displayName: String,
val callUrl: String,
)
data class CalendarShareeSuggestion(
val displayName: String,
/** DAV-принципал: principal:principals/users/<id> или groups/<id>. */
val principal: String,
val isGroup: Boolean,
)
data class CalendarUserSettings(
val timezone: String = "automatic",
val showWeekends: Boolean = true,
@@ -106,6 +119,105 @@ class CalendarApiClient {
}
}
/** Email текущего пользователя (для ORGANIZER в iTIP free-busy). */
fun getUserEmail(session: AuthSession): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "user info")
json.optJSONObject("ocs")?.optJSONObject("data")?.optString("email").orEmpty()
}
}
/** Поиск пользователей/групп для шаринга календаря (OCS sharees). */
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> {
if (query.trim().length < 2) return emptyList()
val client = authedClient(session)
val q = java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v1.php/apps/files_sharing/api/v1/sharees" +
"?format=json&itemType=calendar&perPage=10&search=$q"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "sharees")
val data = json.optJSONObject("ocs")?.optJSONObject("data") ?: return emptyList()
val out = mutableListOf<CalendarShareeSuggestion>()
fun collect(arrName: String, isGroup: Boolean) {
val exactWrap = data.optJSONObject("exact")?.optJSONArray(arrName) ?: JSONArray()
val plain = data.optJSONArray(arrName) ?: JSONArray()
for (arr in listOf(exactWrap, plain)) {
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
val id = item.optJSONObject("value")?.optString("shareWith").orEmpty()
if (id.isBlank()) continue
val kind = if (isGroup) "groups" else "users"
out += CalendarShareeSuggestion(
displayName = item.optString("label").ifBlank { id },
principal = "principal:principals/$kind/$id",
isGroup = isGroup,
)
}
}
}
collect("users", isGroup = false)
collect("groups", isGroup = true)
out.distinctBy { it.principal }
}
}
/** Существующие беседы Talk (для выбора комнаты в событии). */
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "list talk rooms")
if (!isOcsSuccess(json.ocsMeta())) return emptyList()
val data = json.optJSONObject("ocs")?.optJSONArray("data") ?: JSONArray()
val out = mutableListOf<CalendarTalkRoom>()
for (i in 0 until data.length()) {
val room = data.optJSONObject(i) ?: continue
val token = room.optString("token")
if (token.isBlank()) continue
val name = room.optString("displayName").ifBlank { room.optString("name") }
out += CalendarTalkRoom(
token = token,
displayName = name.ifBlank { "Беседа" },
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
)
}
out
}
}
/** Создать беседу заданного типа (2 — групповая/публичная, 3 — публичная по ссылке). */
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
val payload = JSONObject()
.put("roomType", roomType)
.put("roomName", roomName.trim().ifBlank { "Встреча" })
.put("readOnly", 0)
.put("listable", 0)
.toString()
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
if (!isOcsSuccess(json.ocsMeta())) error("Не удалось создать беседу Talk")
val token = json.optJSONObject("ocs")?.optJSONObject("data")?.optString("token").orEmpty()
if (token.isBlank()) error("Не удалось создать беседу Talk")
return CalendarTalkRoom(
token = token,
displayName = roomName.trim().ifBlank { "Встреча" },
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
)
}
}
fun createTalkRoom(session: AuthSession, roomName: String): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
@@ -23,26 +23,26 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -77,7 +77,6 @@ import java.util.Locale
private val weekDayLabels = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
private val dayTitleFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))
private val listDayFormatter = DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.forLanguageTag("ru"))
private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)
private val dayHeaderFormatter = DateTimeFormatter.ofPattern("EE dd.M.yyyy", Locale.forLanguageTag("ru"))
private fun viewModeIconUrl(base: String, mode: CalendarViewMode): String = when (mode) {
@@ -865,6 +864,7 @@ fun CalendarNavigationSidebar(
onOpenTasks: () -> Unit,
onCreateBook: () -> Unit,
onSyncNow: () -> Unit,
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
) {
val base = session.serverUrl.trimEnd('/')
val (myCalendars, deckCalendars) = remember(state.calendars) {
@@ -916,7 +916,7 @@ fun CalendarNavigationSidebar(
onAdd = onCreateBook,
)
myCalendars.forEach { cal ->
CalendarSidebarCalendarRow(cal, onToggleCalendar)
CalendarSidebarCalendarRow(cal, onToggleCalendar, onEditCalendar)
}
if (deckCalendars.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
@@ -1006,6 +1006,7 @@ private fun CalendarSidebarCaption(
private fun CalendarSidebarCalendarRow(
cal: CalendarBookItem,
onToggleCalendar: (String) -> Unit,
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
) {
val accent = calendarAccentColor(cal)
Row(
@@ -1029,7 +1030,14 @@ private fun CalendarSidebarCalendarRow(
color = F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
// Подписки редактировать нельзя (PROPPATCH/шаринг не применимы)
if (onEditCalendar != null && !cal.isSubscription) {
IconButton(onClick = { onEditCalendar(cal) }, modifier = Modifier.size(28.dp)) {
Text("", color = F7Colors.TextSecondary, fontSize = 16.sp)
}
}
}
}
@@ -1217,23 +1225,26 @@ fun CalendarDatePickerDialog(
onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit,
) {
val state = rememberDatePickerState(
initialSelectedDateMillis = selectedDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val millis = state.selectedDateMillis ?: return@TextButton
val day = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
onConfirm(day)
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
},
) {
DatePicker(state = state)
// Нативный DatePickerDialog вместо material3.DatePicker: material3-версия (BOM 2025.02)
// падала при открытии, а платформенный диалог стабилен и сам локализуется под системную
// локаль (русские месяцы). Показываем один раз, пока композабл присутствует в дереве.
val context = LocalContext.current
val currentOnConfirm by rememberUpdatedState(onConfirm)
val currentOnDismiss by rememberUpdatedState(onDismiss)
DisposableEffect(Unit) {
val dialog = android.app.DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
currentOnConfirm(LocalDate.of(year, month + 1, dayOfMonth))
},
selectedDay.year,
selectedDay.monthValue - 1,
selectedDay.dayOfMonth,
)
// onDismiss закрывает и по «Отмена», и после выбора (диалог сам себя закрывает).
dialog.setOnDismissListener { currentOnDismiss() }
dialog.show()
onDispose { dialog.setOnDismissListener(null); dialog.dismiss() }
}
}
@@ -69,6 +69,8 @@ data class CalendarTaskItem(
data class CalendarEventDraft(
val title: String = "",
val date: java.time.LocalDate = java.time.LocalDate.now(),
/** Дата окончания; null = совпадает с датой начала (однодневное событие). */
val endDate: java.time.LocalDate? = null,
val startTime: String = "10:00",
val endTime: String = "11:00",
val allDay: Boolean = false,
@@ -82,9 +84,17 @@ data class CalendarEventDraft(
val classification: EventClassification = EventClassification.PUBLIC,
val attendees: List<CalendarAttendeeItem> = emptyList(),
val attendeeQuery: String = "",
val reminderMinutes: Int = 15,
/** Напоминания в минутах до начала (0 = во время начала); поддерживается несколько. */
val reminders: List<Int> = listOf(15),
val addTalkRoom: Boolean = false,
val talkRoomUrl: String = "",
val attachments: List<CalendarAttachmentItem> = emptyList(),
)
data class CalendarAttachmentItem(
val fileName: String,
val url: String,
val mimeType: String = "",
)
data class CalendarCreateBookDraft(
@@ -1,18 +1,24 @@
package ru.forbion.f7cloud.feature.calendar
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.CalendarAlarmData
import ru.forbion.f7cloud.core.network.CalendarAttachmentData
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
import ru.forbion.f7cloud.core.network.CalendarEventData
import ru.forbion.f7cloud.core.network.CalendarIcs
import ru.forbion.f7cloud.core.network.CalDavClient
import ru.forbion.f7cloud.core.network.DavCalendar
import ru.forbion.f7cloud.core.network.DavClient
import ru.forbion.f7cloud.core.network.DavEvent
import ru.forbion.f7cloud.core.network.DavTask
import ru.forbion.f7cloud.core.network.DavTrashEvent
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.davFileUrl
import ru.forbion.f7cloud.core.network.davFolderUrl
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
@@ -104,13 +110,111 @@ class CalendarRepository(
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> = apiClient.listTalkRooms(session)
private fun davUserId(session: AuthSession): String =
session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
// --- Управление календарём ---
fun updateCalendarProps(session: AuthSession, calendarHref: String, displayName: String?, color: String?) {
CalDavClient.updateCalendarProps(davClient(session), calendarHref, displayName, color)
}
fun deleteCalendar(session: AuthSession, calendarHref: String) {
CalDavClient.deleteCalendar(davClient(session), calendarHref)
}
fun listCalendarShares(session: AuthSession, calendarHref: String): List<CalDavClient.DavSharee> =
CalDavClient.listCalendarShares(davClient(session), calendarHref)
fun shareCalendar(session: AuthSession, calendarHref: String, principal: String, writable: Boolean) {
CalDavClient.shareCalendar(davClient(session), calendarHref, principal, writable)
}
fun unshareCalendar(session: AuthSession, calendarHref: String, principal: String) {
CalDavClient.unshareCalendar(davClient(session), calendarHref, principal)
}
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> =
apiClient.searchSharees(session, query)
/** Занятость участников (email → интервалы) за диапазон. */
fun freeBusy(
session: AuthSession,
attendeeEmails: List<String>,
rangeStart: Instant,
rangeEnd: Instant,
): Map<String, List<CalDavClient.BusyPeriod>> {
val organizer = apiClient.getUserEmail(session)
if (organizer.isBlank()) error("У пользователя не задан email — занятость недоступна")
return CalDavClient.freeBusy(
davClient(session),
session.serverUrl,
davUserId(session),
organizer,
attendeeEmails,
rangeStart,
rangeEnd,
)
}
private fun davClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
/** Загрузка файла с устройства в папку /Calendar и возврат ATTACH-элемента (WebDAV-URL). */
fun uploadAttachment(
session: AuthSession,
fileName: String,
mimeType: String,
bytes: ByteArray,
): CalendarAttachmentItem {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val userId = davUserId(session)
// Папка для вложений календаря; создаём best-effort (уже существует → игнорируем).
runCatching { DavClient.mkcol(client, davFolderUrl(session.serverUrl, userId, "Calendar")) }
val safeName = sanitizeFileName(fileName)
val fileUrl = davFileUrl(session.serverUrl, userId, "Calendar/$safeName")
val media = mimeType.ifBlank { "application/octet-stream" }.toMediaType()
DavClient.put(client, fileUrl, bytes.toRequestBody(media))
return CalendarAttachmentItem(fileName = safeName, url = fileUrl, mimeType = mimeType)
}
/** Список файлов/папок в директории (для пикера «Добавить из файлов»). relativePath — от корня. */
fun listUserFiles(session: AuthSession, relativePath: String): List<DavClient.DavEntry> {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val userId = davUserId(session)
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
val entries = DavClient.propfind(client, folderUrl, depth = 1)
val self = folderUrl.trimEnd('/')
// Убираем саму папку (первый ответ PROPFIND) и скрытые файлы.
return entries
.filter { it.href.trimEnd('/') != self && !it.name.startsWith('.') }
.sortedWith(compareByDescending<DavClient.DavEntry> { it.isDirectory }.thenBy { it.name.lowercase() })
}
/** Полный WebDAV-URL файла (для ATTACH при выборе из Файлов). */
fun fileAttachment(session: AuthSession, relativePath: String, mimeType: String): CalendarAttachmentItem {
val userId = davUserId(session)
val url = davFileUrl(session.serverUrl, userId, relativePath)
return CalendarAttachmentItem(fileName = relativePath.substringAfterLast('/'), url = url, mimeType = mimeType)
}
private fun sanitizeFileName(name: String): String =
name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
.replace(Regex("[\\r\\n\"]"), "_")
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom =
apiClient.createTalkRoomTyped(session, roomName, roomType)
fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
apiClient.addTalkParticipants(session, callUrl, emails)
}
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
val picked = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
// Если выбранная коллекция не принимает VEVENT (список задач) — уводим в событийный дефолт.
val calendar = if (picked.supportsEvents) picked else ctx.defaultCalendar
var talkUrl = draft.talkRoomUrl
if (draft.addTalkRoom && talkUrl.isBlank()) {
talkUrl = apiClient.createTalkRoom(session, draft.title)
@@ -197,10 +301,12 @@ class CalendarRepository(
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
val calendars = CalDavClient.listCalendars(client, base)
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
val default = calendars.firstOrNull { cal ->
// Дефолт под события — только событийная коллекция (не VTODO-список задач), иначе PUT 403.
val eventCals = calendars.filter { it.supportsEvents }.ifEmpty { calendars }
val default = eventCals.firstOrNull { cal ->
val name = cal.displayName.lowercase()
name.contains("личн") || name.contains("personal") || name == "calendar"
} ?: calendars.first()
} ?: eventCals.first()
return CalDavContext(client, calendars, default)
}
@@ -264,6 +370,7 @@ class CalendarRepository(
organizerEmail = organizerEmail,
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
source = CalendarEventSource.F7CLOUD,
)
@@ -361,23 +468,27 @@ data class CalendarEventItem(
val organizerEmail: String = "",
val talkRoomUrl: String = "",
val alarms: List<CalendarReminderItem> = emptyList(),
val attachments: List<CalendarAttachmentItem> = emptyList(),
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
)
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
val zone = ZoneId.systemDefault()
// Многодневные события: дата окончания отдельная (не раньше даты начала).
val endDay = (endDate ?: date).coerceAtLeast(date)
val start = if (allDay) {
date.atStartOfDay(zone).toInstant().toEpochMilli()
} else {
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
}
val end = if (allDay) {
date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val endRaw = if (allDay) {
endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
} else {
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
parseDateTime(endDay, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: endDay.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
}
val end = endRaw.coerceAtLeast(start)
val rrule = when {
recurrence != RecurrencePreset.NONE -> recurrence.rrule
customRrule.isNotBlank() -> customRrule
@@ -400,8 +511,9 @@ private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String
attendees = attendees.map {
CalendarAttendeeData(it.email, it.displayName, it.partStat)
},
alarms = if (reminderMinutes > 0) listOf(CalendarAlarmData(reminderMinutes)) else emptyList(),
alarms = reminders.filter { it >= 0 }.distinct().map { CalendarAlarmData(it) },
conferenceUri = conf,
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
)
}
@@ -421,6 +533,7 @@ private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventDa
organizerEmail = organizerEmail,
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
conferenceUri = talkRoomUrl,
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
)
private fun CalendarEventData.toItem(
@@ -448,6 +561,7 @@ private fun CalendarEventData.toItem(
attendees = attendees.map { it.toItem() },
talkRoomUrl = conferenceUri,
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
)
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
@@ -74,6 +74,9 @@ fun CalendarScreen(
vm.updateEventDraft { it } // no-op; snack via error path if needed
}
}
val attachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) vm.uploadDeviceAttachment(uri)
}
fun hasCalendarPermission(): Boolean {
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
@@ -220,6 +223,48 @@ fun CalendarScreen(
onRemoveAttendee = vm::removeAttendee,
onSearchLocations = vm::searchLocations,
onApplyLocation = vm::applyLocationSuggestion,
onOpenTalkPicker = vm::openTalkPicker,
onPickDeviceAttachment = { attachmentLauncher.launch("*/*") },
onPickFilesAttachment = vm::openFilesPicker,
onRemoveAttachment = vm::removeAttachment,
onOpenFreeBusy = vm::openFreeBusy,
)
}
if (state.freeBusyOpen) {
CalendarFreeBusyDialog(
state = state,
onDismiss = vm::closeFreeBusy,
)
}
if (state.talkPickerOpen) {
CalendarTalkPickerDialog(
state = state,
onDismiss = vm::closeTalkPicker,
onSelect = vm::selectTalkRoom,
onCreate = vm::createTalkConversation,
)
}
if (state.filesPickerOpen) {
CalendarFilesPickerDialog(
state = state,
onDismiss = vm::closeFilesPicker,
onNavigate = vm::navigateFilesPicker,
onPick = vm::pickFileAttachment,
)
}
if (state.editCalendarOpen) {
CalendarEditBookDialog(
state = state,
onDismiss = vm::closeEditCalendar,
onSave = vm::saveCalendarProps,
onDelete = vm::deleteCalendarConfirmed,
onSearchSharees = vm::searchSharees,
onAddSharee = vm::addSharee,
onRemoveSharee = vm::removeSharee,
)
}
@@ -287,6 +332,11 @@ fun CalendarScreen(
onSyncNow = {
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
},
onEditCalendar = { cal ->
onSidebarOpenChange(false)
scope.launch { drawerState.close() }
vm.openEditCalendar(cal)
},
)
}
},
@@ -45,6 +45,23 @@ data class CalendarUiState(
val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
val talkPickerOpen: Boolean = false,
val talkPickerLoading: Boolean = false,
val talkRooms: List<CalendarTalkRoom> = emptyList(),
val attachmentUploading: Boolean = false,
val editCalendarOpen: Boolean = false,
val editCalendarTarget: CalendarBookItem? = null,
val editCalendarShares: List<ru.forbion.f7cloud.core.network.CalDavClient.DavSharee> = emptyList(),
val shareeSuggestions: List<CalendarShareeSuggestion> = emptyList(),
val editCalendarBusy: Boolean = false,
val freeBusyOpen: Boolean = false,
val freeBusyLoading: Boolean = false,
val freeBusyError: String? = null,
val freeBusyResult: Map<String, List<ru.forbion.f7cloud.core.network.CalDavClient.BusyPeriod>> = emptyMap(),
val filesPickerOpen: Boolean = false,
val filesPickerLoading: Boolean = false,
val filesPickerPath: String = "",
val filesPickerEntries: List<ru.forbion.f7cloud.core.network.DavClient.DavEntry> = emptyList(),
val userSettings: CalendarUserSettings = CalendarUserSettings(),
val saving: Boolean = false,
val deleting: Boolean = false,
@@ -362,7 +379,7 @@ class CalendarViewModel(
eventDraft = CalendarEventDraft(
date = targetDay,
calendarHref = createHref,
reminderMinutes = _state.value.userSettings.defaultReminderMinutes,
reminders = listOf(_state.value.userSettings.defaultReminderMinutes),
),
)
}
@@ -410,6 +427,212 @@ class CalendarViewModel(
_state.value = _state.value.copy(locationSuggestions = emptyList())
}
fun openTalkPicker() {
val s = session ?: return
_state.value = _state.value.copy(talkPickerOpen = true, talkPickerLoading = true, talkRooms = emptyList())
viewModelScope.launch(Dispatchers.IO) {
val rooms = runCatching { repository.listTalkRooms(s) }.getOrDefault(emptyList())
_state.value = _state.value.copy(talkRooms = rooms, talkPickerLoading = false)
}
}
fun closeTalkPicker() { _state.value = _state.value.copy(talkPickerOpen = false) }
fun selectTalkRoom(room: CalendarTalkRoom) {
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
_state.value = _state.value.copy(talkPickerOpen = false)
}
/** roomType: 2 — публичная беседа, 3 — приватная (в терминах spreed). */
fun createTalkConversation(roomType: Int) {
val s = session ?: return
val name = _state.value.eventDraft.title.ifBlank { "Встреча" }
_state.value = _state.value.copy(talkPickerLoading = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.createTalkRoomTyped(s, name, roomType) }
.onSuccess { room ->
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
_state.value = _state.value.copy(talkPickerOpen = false, talkPickerLoading = false)
}
.onFailure { _state.value = _state.value.copy(talkPickerLoading = false, snackMessage = "Не удалось создать беседу") }
}
}
// --- Управление календарём ---
fun openEditCalendar(cal: CalendarBookItem) {
val s = session ?: return
_state.value = _state.value.copy(
editCalendarOpen = true,
editCalendarTarget = cal,
editCalendarShares = emptyList(),
shareeSuggestions = emptyList(),
)
viewModelScope.launch(Dispatchers.IO) {
val shares = runCatching { repository.listCalendarShares(s, cal.href) }.getOrDefault(emptyList())
_state.value = _state.value.copy(editCalendarShares = shares)
}
}
fun closeEditCalendar() {
_state.value = _state.value.copy(editCalendarOpen = false, editCalendarTarget = null, shareeSuggestions = emptyList())
}
fun saveCalendarProps(displayName: String, color: String?) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
_state.value = _state.value.copy(editCalendarBusy = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.updateCalendarProps(
s,
target.href,
displayName.trim().takeIf { it.isNotBlank() && it != target.displayName },
color?.takeIf { it != target.color },
)
}
.onSuccess {
loadCalendars(s)
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь обновлён")
}
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось обновить: ${it.message}") }
}
}
fun deleteCalendarConfirmed() {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
_state.value = _state.value.copy(editCalendarBusy = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.deleteCalendar(s, target.href) }
.onSuccess {
loadCalendars(s)
reloadEvents(s)
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь удалён")
}
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось удалить: ${it.message}") }
}
}
fun searchSharees(query: String) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
val found = runCatching { repository.searchSharees(s, query) }.getOrDefault(emptyList())
_state.value = _state.value.copy(shareeSuggestions = found)
}
}
fun addSharee(sharee: CalendarShareeSuggestion, writable: Boolean) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.shareCalendar(s, target.href, sharee.principal, writable) }
.onSuccess {
val shares = runCatching { repository.listCalendarShares(s, target.href) }.getOrDefault(emptyList())
_state.value = _state.value.copy(editCalendarShares = shares, shareeSuggestions = emptyList())
}
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось пошарить: ${it.message}") }
}
}
fun removeSharee(principal: String) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.unshareCalendar(s, target.href, principal) }
.onSuccess {
_state.value = _state.value.copy(
editCalendarShares = _state.value.editCalendarShares.filterNot { it.principal == principal },
)
}
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось убрать доступ: ${it.message}") }
}
}
// --- Занятость участников (free/busy) ---
fun openFreeBusy() {
val s = session ?: return
val draft = _state.value.eventDraft
val emails = draft.attendees.map { it.email }.filter { it.isNotBlank() }
if (emails.isEmpty()) return
_state.value = _state.value.copy(freeBusyOpen = true, freeBusyLoading = true, freeBusyError = null, freeBusyResult = emptyMap())
val zone = ZoneId.systemDefault()
// Диапазон — весь день события (локальные сутки).
val start = draft.date.atStartOfDay(zone).toInstant()
val end = draft.date.plusDays(1).atStartOfDay(zone).toInstant()
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.freeBusy(s, emails, start, end) }
.onSuccess { _state.value = _state.value.copy(freeBusyResult = it, freeBusyLoading = false) }
.onFailure { _state.value = _state.value.copy(freeBusyLoading = false, freeBusyError = it.message) }
}
}
fun closeFreeBusy() { _state.value = _state.value.copy(freeBusyOpen = false) }
// --- Вложения ---
fun uploadDeviceAttachment(uri: android.net.Uri) {
val s = session ?: return
_state.value = _state.value.copy(attachmentUploading = true)
viewModelScope.launch(Dispatchers.IO) {
val resolver = appContext.contentResolver
val name = queryDisplayName(uri) ?: "attachment"
val mime = resolver.getType(uri).orEmpty()
val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
if (bytes == null || bytes.isEmpty()) {
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Не удалось прочитать файл")
return@launch
}
runCatching { repository.uploadAttachment(s, name, mime, bytes) }
.onSuccess { item ->
updateEventDraft { it.copy(attachments = it.attachments + item) }
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Файл загружен")
}
.onFailure { _state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Ошибка загрузки: ${it.message}") }
}
}
fun openFilesPicker() {
_state.value = _state.value.copy(filesPickerOpen = true)
loadFilesPicker("")
}
fun closeFilesPicker() { _state.value = _state.value.copy(filesPickerOpen = false) }
fun navigateFilesPicker(path: String) = loadFilesPicker(path)
private fun loadFilesPicker(path: String) {
val s = session ?: return
_state.value = _state.value.copy(filesPickerLoading = true, filesPickerPath = path)
viewModelScope.launch(Dispatchers.IO) {
val entries = runCatching { repository.listUserFiles(s, path) }.getOrDefault(emptyList())
_state.value = _state.value.copy(filesPickerEntries = entries, filesPickerLoading = false)
}
}
fun pickFileAttachment(entry: ru.forbion.f7cloud.core.network.DavClient.DavEntry) {
val s = session ?: return
val path = _state.value.filesPickerPath
val rel = if (path.isBlank()) entry.name else "$path/${entry.name}"
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.fileAttachment(s, rel, entry.mimeType.orEmpty()) }
.onSuccess { item ->
updateEventDraft { it.copy(attachments = it.attachments + item) }
_state.value = _state.value.copy(filesPickerOpen = false, snackMessage = "Файл прикреплён")
}
}
}
fun removeAttachment(url: String) {
updateEventDraft { d -> d.copy(attachments = d.attachments.filterNot { it.url == url }) }
}
private fun queryDisplayName(uri: android.net.Uri): String? =
runCatching {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
}
}.getOrNull() ?: uri.lastPathSegment
fun openEventDetail(event: CalendarEventItem) {
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
}
@@ -428,6 +651,11 @@ class CalendarViewModel(
eventDraft = CalendarEventDraft(
title = event.summary,
date = start.toLocalDate(),
// DTEND эксклюзивен для all-day → показываем последний включённый день.
endDate = run {
val endDay = if (event.allDay) end.toLocalDate().minusDays(1) else end.toLocalDate()
endDay.coerceAtLeast(start.toLocalDate()).takeIf { it != start.toLocalDate() }
},
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
allDay = event.allDay,
@@ -440,9 +668,10 @@ class CalendarViewModel(
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
attendees = event.attendees,
reminderMinutes = event.alarms.firstOrNull()?.minutesBefore ?: 15,
reminders = event.alarms.map { it.minutesBefore }.ifEmpty { emptyList() },
talkRoomUrl = event.talkRoomUrl,
addTalkRoom = event.talkRoomUrl.isNotBlank(),
attachments = event.attachments,
),
focusedEventUid = event.uid,
)
@@ -541,10 +770,10 @@ class CalendarViewModel(
CalendarViewMode.LIST -> "Предстоящие события"
CalendarViewMode.WEEK -> {
val (start, end) = CalendarRepository.weekRange(st.selectedDay)
"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"
"${start.format(DateTimeFormatter.ofPattern("d MMM", RU))} ${end.format(DateTimeFormatter.ofPattern("d MMM yyyy", RU))}"
}
CalendarViewMode.DAY, CalendarViewMode.MONTH ->
st.selectedDay.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))
st.selectedDay.format(DateTimeFormatter.ofPattern("d MMMM yyyy", RU))
}
}
@@ -642,6 +871,7 @@ class CalendarViewModel(
companion object {
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
private val RU: Locale = Locale.forLanguageTag("ru")
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", RU)
}
}
@@ -0,0 +1,88 @@
package ru.forbion.f7cloud.feature.calendar
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/** Разобранное правило повторения (подмножество RFC 5545 RRULE, как в веб-версии). */
data class RecurrenceRule(
val freq: String = "WEEKLY", // DAILY | WEEKLY | MONTHLY | YEARLY
val interval: Int = 1,
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
val byDays: Set<String> = emptySet(),
val endType: EndType = EndType.NEVER,
val untilDate: LocalDate? = null,
val count: Int = 10,
) {
enum class EndType { NEVER, UNTIL, COUNT }
fun toRrule(): String = buildString {
append("FREQ=$freq")
if (interval > 1) append(";INTERVAL=$interval")
if (freq == "WEEKLY" && byDays.isNotEmpty()) {
append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}")
}
when (endType) {
EndType.UNTIL -> untilDate?.let { append(";UNTIL=${it.format(UNTIL_FMT)}T235959Z") }
EndType.COUNT -> append(";COUNT=${count.coerceAtLeast(1)}")
EndType.NEVER -> Unit
}
}
companion object {
val ICS_DAYS = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU")
val DAY_LABELS = mapOf(
"MO" to "Пн", "TU" to "Вт", "WE" to "Ср", "TH" to "Чт",
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
)
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */
fun parse(rrule: String): RecurrenceRule? {
if (rrule.isBlank()) return null
val parts = rrule.trim().removePrefix("RRULE:").split(';')
.mapNotNull { p ->
val idx = p.indexOf('=')
if (idx <= 0) null else p.substring(0, idx).uppercase() to p.substring(idx + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase() ?: return null
if (freq !in setOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY")) return null
val until = parts["UNTIL"]?.let { raw ->
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
}
val count = parts["COUNT"]?.toIntOrNull()
return RecurrenceRule(
freq = freq,
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() }
?.filter { it in ICS_DAYS }?.toSet().orEmpty(),
endType = when {
until != null -> EndType.UNTIL
count != null -> EndType.COUNT
else -> EndType.NEVER
},
untilDate = until,
count = count ?: 10,
)
}
/** Человекочитаемое описание правила по-русски (для пилюли «Повторение»). */
fun describe(rrule: String): String {
val rule = parse(rrule) ?: return "Не повторять"
val freqLabel = when (rule.freq) {
"DAILY" -> if (rule.interval == 1) "Каждый день" else "Каждые ${rule.interval} дн."
"WEEKLY" -> if (rule.interval == 1) "Каждую неделю" else "Каждые ${rule.interval} нед."
"MONTHLY" -> if (rule.interval == 1) "Каждый месяц" else "Каждые ${rule.interval} мес."
else -> if (rule.interval == 1) "Каждый год" else "Каждые ${rule.interval} г."
}
val days = if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
" по " + ICS_DAYS.filter { it in rule.byDays }.joinToString(", ") { DAY_LABELS[it] ?: it }
} else ""
val end = when (rule.endType) {
EndType.UNTIL -> rule.untilDate?.let { " до ${it.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}" }.orEmpty()
EndType.COUNT -> ", ${rule.count} раз"
EndType.NEVER -> ""
}
return freqLabel + days + end
}
}
}
@@ -50,9 +50,17 @@ class DeckRepository {
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
val c = client(session)
val board = getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject
val labels = parseLabels(board.optJSONArray("labels"))
val canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
// Доска-объект (метки/права) — НЕ критична: если запрос упадёт, доску всё равно
// открываем со стеками (раньше был баг — сбой 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 stacksJson.length()) {
@@ -74,7 +82,7 @@ class DeckRepository {
}
return DeckBoardDetail(
boardId = boardId,
title = board.optString("title"),
title = boardTitle,
canEdit = canEdit,
labels = labels,
stacks = stacks.sortedBy { it.order },
@@ -181,7 +189,8 @@ class DeckRepository {
// --- HTTP ---
private fun getJson(client: OkHttpClient, url: String): Any {
val request = Request.Builder().url(url).header("OCS-APIRequest", "true").build()
// 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}")
val body = response.body!!.string().trim()
@@ -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)
}
@@ -241,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)
@@ -472,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,
)
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.tasks
/**
* Лёгкий кэш в памяти на время жизни процесса: списки задач и задачи по каждому списку.
* VM пересоздаётся при каждом открытии раздела Задач без кэша это полный PROPFIND+REPORT
* заново. Кэш даёт мгновенный показ, сеть обновляет в фоне.
*/
internal object TasksCache {
@Volatile
private var lists: List<TaskListItem>? = null
private val tasksByList = java.util.concurrent.ConcurrentHashMap<String, List<TaskItem>>()
fun cachedLists(): List<TaskListItem>? = lists
fun putLists(value: List<TaskListItem>) {
lists = value
}
fun cachedTasks(listHref: String): List<TaskItem>? = tasksByList[listHref]
fun putTasks(listHref: String, value: List<TaskItem>) {
tasksByList[listHref] = value
}
fun invalidateTasks(listHref: String) {
tasksByList.remove(listHref)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -345,7 +346,10 @@ fun TasksRow(
onToggleComplete: () -> Unit,
onOpen: () -> Unit,
modifier: Modifier = Modifier,
onAddSubtask: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
) {
var menuOpen by remember { mutableStateOf(false) }
val hasMeta = showMeta && (task.listName.isNotBlank() || task.percentComplete in 1..99 || task.due.isNotBlank())
Column(
modifier = modifier
@@ -401,6 +405,43 @@ fun TasksRow(
color = task.dueDateColor(),
)
}
// Меню «⋯»: вложенная задача / удалить (как в веб-версии)
if (onAddSubtask != null || onDelete != null) {
Box {
Text(
"",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = F7Colors.TextSecondary,
modifier = Modifier
.size(24.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { menuOpen = true },
),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
androidx.compose.material3.DropdownMenu(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
containerColor = androidx.compose.ui.graphics.Color.White,
) {
onAddSubtask?.let { add ->
androidx.compose.material3.DropdownMenuItem(
text = { Text("Добавить вложенную задачу", color = F7Colors.TextPrimary) },
onClick = { menuOpen = false; add() },
)
}
onDelete?.let { del ->
androidx.compose.material3.DropdownMenuItem(
text = { Text("Удалить задачу", color = F7Colors.Error) },
onClick = { menuOpen = false; del() },
)
}
}
}
}
}
}
if (hasMeta) {
@@ -1064,3 +1105,42 @@ private fun SmartListSettingRow(
}
}
}
/** Диалог создания вложенной задачи (подзадачи) — по кнопке «Добавить вложенную задачу». */
@Composable
fun TaskSubtaskDialog(
parentTitle: 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(
"Вложенная задача",
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Text(
"к «$parentTitle»",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
F7OutlinedField(value = text, onValueChange = { text = it }, label = "Название")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(
text = "Создать",
onClick = { onConfirm(text.trim()) },
enabled = text.isNotBlank(),
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@@ -29,6 +29,7 @@ data class TaskItem(
val description: String,
val listHref: String,
val listName: String,
val parentUid: String = "",
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
@@ -46,6 +47,7 @@ data class TaskItem(
description = description,
calendarName = listName,
calendarHref = listHref,
parentUid = parentUid,
)
}
@@ -80,6 +82,7 @@ class TasksRepository {
dueDate: LocalDate? = null,
priority: Int = 0,
description: String = "",
parentUid: String = "",
): TaskItem {
val client = authedClient(session)
val calendar = calendarForHref(session, listHref)
@@ -90,6 +93,7 @@ class TasksRepository {
dueDate = dueDate,
priority = priority,
description = description,
parentUid = parentUid,
).toTaskItem()
}
@@ -157,6 +161,7 @@ class TasksRepository {
description = description,
listHref = calendarHref,
listName = calendarName,
parentUid = parentUid,
)
private fun calendarSlug(href: String): String {
@@ -17,6 +17,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -31,11 +34,19 @@ fun TasksScreen(
modifier: Modifier = Modifier,
createRequest: Int = 0,
openListSlug: String? = null,
showListsRequest: Int = 0,
onOpenListConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val vm: TasksViewModel = viewModel()
val state by vm.state.collectAsState()
// Родитель для создаваемой подзадачи (открывает диалог ввода названия).
var subtaskParent by remember { mutableStateOf<TaskItem?>(null) }
// Стрелка «назад» нижней панели на Задачах → показать панель списков (закрыть открытый список).
LaunchedEffect(showListsRequest) {
if (showListsRequest > 0) vm.closeList()
}
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
vm.load(session)
@@ -103,6 +114,8 @@ fun TasksScreen(
onReload = { vm.refreshTasks(session) },
onToggleCompletedExpanded = vm::toggleCompletedExpanded,
onDeleteCompleted = { vm.deleteCompletedTasks(session) },
onAddSubtask = { subtaskParent = it },
onDeleteTask = { vm.deleteTask(session, it) },
)
}
}
@@ -143,6 +156,18 @@ fun TasksScreen(
onDismiss = vm::closeSettings,
onSwipeBack = vm::closeSettings,
)
// Диалог «Добавить вложенную задачу»
subtaskParent?.let { parent ->
TaskSubtaskDialog(
parentTitle = parent.summary,
onConfirm = { title ->
vm.createSubtask(session, parent, title)
subtaskParent = null
},
onDismiss = { subtaskParent = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -162,7 +187,15 @@ private fun TasksMainContent(
onReload: () -> Unit,
onToggleCompletedExpanded: () -> Unit,
onDeleteCompleted: () -> Unit,
onAddSubtask: (TaskItem) -> Unit,
onDeleteTask: (TaskItem) -> Unit,
) {
// Подзадачи (RELATED-TO) — рисуем под родителем с отступом; из верхнего уровня исключаем.
val childrenByParent = remember(state.visibleActiveGroups) {
state.visibleActiveGroups.flatMap { it.tasks }
.filter { it.parentUid.isNotBlank() }
.groupBy { it.parentUid }
}
Column(modifier = Modifier.fillMaxSize()) {
TasksToolbar(
serverUrl = session.serverUrl,
@@ -212,13 +245,27 @@ private fun TasksMainContent(
modifier = Modifier.padding(bottom = 4.dp),
)
}
items(group.tasks, key = { it.uid }) { task ->
TasksRow(
task = task,
serverUrl = session.serverUrl,
onToggleComplete = { onToggleComplete(task) },
onOpen = { onOpenTask(task) },
)
items(group.tasks.filter { it.parentUid.isBlank() }, key = { it.uid }) { task ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
TasksRow(
task = task,
serverUrl = session.serverUrl,
onToggleComplete = { onToggleComplete(task) },
onOpen = { onOpenTask(task) },
onAddSubtask = { onAddSubtask(task) },
onDelete = { onDeleteTask(task) },
)
childrenByParent[task.uid].orEmpty().forEach { sub ->
TasksRow(
task = sub,
serverUrl = session.serverUrl,
onToggleComplete = { onToggleComplete(sub) },
onOpen = { onOpenTask(sub) },
onDelete = { onDeleteTask(sub) },
modifier = Modifier.padding(start = 24.dp),
)
}
}
}
}
@@ -74,10 +74,27 @@ class TasksViewModel(
val state: StateFlow<TasksUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
// Мгновенно показываем кэш (списки + задачи выбранного), сеть обновляет в фоне.
val cachedLists = TasksCache.cachedLists()
if (cachedLists != null && cachedLists.isNotEmpty()) {
val selected = _state.value.selectedListHref
?: _state.value.defaultListHref
?: cachedLists.firstOrNull()?.href
_state.update {
it.copy(
loading = false,
lists = cachedLists,
selectedListHref = selected,
defaultListHref = it.defaultListHref ?: cachedLists.firstOrNull()?.href,
tasks = selected?.let { href -> TasksCache.cachedTasks(href) } ?: it.tasks,
)
}
}
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(loading = it.lists.isEmpty(), error = null) }
runCatching { repository.listTaskLists(session) }
.onSuccess { lists ->
TasksCache.putLists(lists)
val selected = _state.value.selectedListHref
?: _state.value.defaultListHref
?: lists.firstOrNull()?.href
@@ -107,6 +124,8 @@ class TasksViewModel(
_state.update {
it.copy(
selectedListHref = listHref,
// Мгновенно из кэша, если есть — иначе пусто до загрузки.
tasks = TasksCache.cachedTasks(listHref).orEmpty(),
searchQuery = "",
detailTask = null,
createInputExpanded = false,
@@ -197,6 +216,7 @@ class TasksViewModel(
summary: String,
dueInput: String,
priority: Int,
parentUid: String = "",
) {
val listHref = _state.value.selectedListHref ?: return
if (summary.isBlank()) {
@@ -218,6 +238,7 @@ class TasksViewModel(
summary = summary.trim(),
dueDate = dueDate,
priority = priority,
parentUid = parentUid,
)
}.onSuccess {
_state.update {
@@ -301,6 +322,30 @@ class TasksViewModel(
}
}
/** Удаление конкретной задачи из списка (per-task меню «⋯»), не только открытой детали. */
fun deleteTask(session: AuthSession, task: TaskItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(saving = true, error = null) }
runCatching { repository.deleteTask(session, task) }
.onSuccess {
_state.update {
it.copy(saving = false, detailTask = it.detailTask?.takeIf { d -> d.uid != task.uid })
}
_state.value.selectedListHref?.let { loadTasks(session, it) }
}
.onFailure { t ->
_state.update {
it.copy(saving = false, error = t.message ?: "Не удалось удалить", unauthorized = t is UnauthorizedException)
}
}
}
}
/** Создать подзадачу к [parent] с заголовком [summary]. */
fun createSubtask(session: AuthSession, parent: TaskItem, summary: String) {
createTask(session, summary, "", 0, parentUid = parent.uid)
}
fun deleteDetail(session: AuthSession) {
val task = _state.value.detailTask ?: return
viewModelScope.launch(Dispatchers.IO) {
@@ -355,7 +400,11 @@ class TasksViewModel(
_state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) }
runCatching { repository.loadTasks(session, listHref) }
.onSuccess { tasks ->
_state.update { it.copy(loading = false, tasks = tasks) }
TasksCache.putTasks(listHref, tasks)
// Обновляем список задач, только если пользователь всё ещё на этом списке.
if (_state.value.selectedListHref == listHref) {
_state.update { it.copy(loading = false, tasks = tasks) }
}
}
.onFailure { t ->
_state.update {