6 Commits

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 05:22:23 +00:00
21 changed files with 1053 additions and 204 deletions
+4
View File
@@ -2,6 +2,10 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве - 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие - 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile' applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26 minSdk 26
targetSdk 36 targetSdk 36
versionCode 134 versionCode 136
versionName '0.5.126' versionName '0.5.128'
missingDimensionStrategy 'default', 'f7' missingDimensionStrategy 'default', 'f7'
multiDexEnabled true multiDexEnabled true
@@ -1,10 +1,11 @@
package ru.forbion.f7cloud.mobile.ui package ru.forbion.f7cloud.mobile.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
// Иконки шторки — ПЛОСКИЕ menu-иконки живой темы forbion (images/menu/*.svg: зелёный+чёрный
// контур), а не glass-кружки. Локальные (assets/menu/menu-*.svg) — офлайн и 1:1 с сайтом.
private fun menuIcon(name: String): String = "file:///android_asset/menu/menu-$name.svg"
enum class AppTab( enum class AppTab(
val title: String, val title: String,
val headerIconPath: String, val headerIconPath: String,
@@ -34,12 +35,13 @@ private data class AppMenuEntry(
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только // Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems). // в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
// iconPath — базовое имя плоской menu-иконки (assets/menu/menu-<name>.svg)
private val coreAppMenuEntries = listOf( private val coreAppMenuEntries = listOf(
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"), AppMenuEntry(AppTab.Files, "Файлы", "files"),
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"), AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"), AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"), AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"), AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
) )
data class AppMenuExternalSite( data class AppMenuExternalSite(
@@ -59,7 +61,7 @@ fun appMenuItems(
val core = coreAppMenuEntries.map { entry -> val core = coreAppMenuEntries.map { entry ->
F7AppMenuItem( F7AppMenuItem(
label = entry.label, label = entry.label,
iconUrl = "$base/themes/forbion/images/header/${entry.iconPath}", iconUrl = menuIcon(entry.iconPath),
selected = entry.tab == active, selected = entry.tab == active,
externalUrl = entry.webPath?.let { "$base$it" }, externalUrl = entry.webPath?.let { "$base$it" },
) )
@@ -72,19 +74,17 @@ fun appMenuItems(
externalUrl = site.openUrl, externalUrl = site.openUrl,
) )
} }
// Нативные пункты по дизайну — действия обрабатываются в AppScaffold по label // Нативные пункты — плоские menu-иконки темы, действия по label в AppScaffold
val native = listOf( val native = listOf(
F7AppMenuItem( F7AppMenuItem(
label = "Уведомления", label = "Уведомления",
iconUrl = "", iconUrl = menuIcon("notifications"),
selected = false, selected = false,
localIcon = Icons.Filled.Notifications,
), ),
F7AppMenuItem( F7AppMenuItem(
label = "Настройки", label = "Настройки",
iconUrl = "", iconUrl = menuIcon("settings"),
selected = false, selected = false,
localIcon = Icons.Filled.Settings,
), ),
) )
return core + external + native return core + external + native
@@ -458,7 +458,9 @@ fun AppScaffold(
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
else -> Unit // Разделы без сайдбара (Карточки/Конференции/Контакты/Задачи/
// Поддержка): стрелка = «назад» к предыдущему разделу.
else -> popTabHistory()?.let { activeTab = it }
} }
}, },
onMailClick = { onMailClick = {
@@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1460_27244)">
<path d="M3 3H13.5341" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 13H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1460_27244">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 570 B

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

After

Width:  |  Height:  |  Size: 3.5 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,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,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

@@ -13,21 +13,20 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -53,13 +52,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
// Живой forbion (_header.css .app-menu--mobile): нижний отступ 90 под плавающую панель,
// иконки 62, сетка 3 колонки с gap 8, БЕЗ скругления верха и БЕЗ ручки-хендла,
// высота — по контенту (почти весь экран), фон #FBFBFB.
private val BottomBarReserve: Dp = 90.dp private val BottomBarReserve: Dp = 90.dp
private val MenuIconSize = 62.dp private val MenuIconSize = 62.dp
private val MenuGridGap = 20.dp private val MenuGridGap = 8.dp
// Меню-лист выезжает снизу и занимает ~пол-экрана (не весь), по требованию владельца
private const val MenuSheetHeightFraction = 0.6f
private val MenuSheetShape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
data class F7AppMenuItem( data class F7AppMenuItem(
val label: String, val label: String,
@@ -110,7 +108,8 @@ fun F7AppMenuSheet(
), ),
) )
} }
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень // Сам лист — выезжает снизу вверх (translateY 100%→0), как в живом forbion:
// без скругления верха и без ручки, высота по контенту до почти всего экрана.
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(250)) + slideInVertically( enter = fadeIn(tween(250)) + slideInVertically(
@@ -126,61 +125,47 @@ fun F7AppMenuSheet(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(MenuSheetHeightFraction) .heightIn(max = 640.dp)
.padding(bottom = BottomBarReserve)
.navigationBarsPadding()
.shadow(16.dp, MenuSheetShape, clip = false)
.clip(MenuSheetShape)
.background(F7Colors.Background) .background(F7Colors.Background)
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), .navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = BottomBarReserve)
.verticalScroll(rememberScrollState()),
) { ) {
// Ручка-хендл
Box(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 2.dp, bottom = 10.dp)
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(F7Colors.Border),
)
F7AppMenuSearchField( F7AppMenuSearchField(
serverUrl = base, serverUrl = base,
value = searchQuery, value = searchQuery,
onValueChange = { searchQuery = it }, onValueChange = { searchQuery = it },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 20.dp), .padding(bottom = 24.dp),
) )
LazyVerticalGrid( // Сетка 3 колонки, gap 8 (живой forbion). Не-ленивая — лист сам в verticalScroll.
columns = GridCells.Fixed(3), Column(
horizontalArrangement = Arrangement.spacedBy(MenuGridGap), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MenuGridGap), verticalArrangement = Arrangement.spacedBy(MenuGridGap),
contentPadding = PaddingValues(horizontal = 2.dp),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) { ) {
itemsIndexed( filteredItems.chunked(3).forEach { rowItems ->
items = filteredItems, Row(
key = { index, item -> "${item.label}-$index" }, modifier = Modifier.fillMaxWidth(),
) { index, item -> horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
val originalIndex = items.indexOf(item) ) {
Box( rowItems.forEach { item ->
modifier = Modifier.fillMaxWidth(), val originalIndex = items.indexOf(item)
contentAlignment = Alignment.TopCenter, Box(
) { modifier = Modifier.weight(1f),
F7AppMenuGridItem( contentAlignment = Alignment.TopCenter,
item = item, ) {
onClick = { F7AppMenuGridItem(
if (originalIndex >= 0) { item = item,
onItemClick(originalIndex) onClick = { if (originalIndex >= 0) onItemClick(originalIndex) },
)
} }
}, }
) // добить ряд пустыми ячейками для выравнивания по левому краю
repeat(3 - rowItems.size) { Box(modifier = Modifier.weight(1f)) {} }
}
} }
} }
}
} }
} }
} }
@@ -9,7 +9,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -34,17 +35,19 @@ data class F7BottomBarActions(
val onMenuClick: () -> Unit = {}, val onMenuClick: () -> Unit = {},
) )
// Живой 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 BottomBarButtonSize = 55.dp
private val BottomBarIconSize = 34.dp private val BottomBarIconSize = 34.dp
// Иконки-разделы (Почта/Карточки/Конференции) — серверные SVG с внутренним
// «воздухом», поэтому рендерим крупнее, чтобы заполняли кружок.
private val BottomBarSectionIconSize = 46.dp private val BottomBarSectionIconSize = 46.dp
private val BottomBarGap = 8.dp private val BottomBarOuterPadding = 3.dp
private val BottomBarOuterPaddingH = 6.dp
private val BottomBarOuterPaddingV = 6.dp
private val BottomBarButtonShape = RoundedCornerShape(100.dp) private val BottomBarButtonShape = RoundedCornerShape(100.dp)
private val BottomBarBorderBrush = Brush.linearGradient( private val BottomBarButtonBg = Color(0xFFFBFBFB)
private val BottomBarButtonBorder = Color(0xFFE6E6E6)
private val BottomBarActiveBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)), listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
) )
@@ -62,10 +65,11 @@ fun F7MobileBottomBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val base = serverUrl.trimEnd('/') val base = serverUrl.trimEnd('/')
val pillShape = RoundedCornerShape(percent = 50) val pillShape = RoundedCornerShape(100.dp)
Row( Row(
modifier = modifier modifier = modifier
.wrapContentWidth() .width(BottomBarWidth)
.height(BottomBarHeight)
.shadow( .shadow(
elevation = 2.dp, elevation = 2.dp,
shape = pillShape, shape = pillShape,
@@ -73,11 +77,8 @@ fun F7MobileBottomBar(
) )
.clip(pillShape) .clip(pillShape)
.background(Color(0xFFF5F5F5)) .background(Color(0xFFF5F5F5))
.padding( .padding(BottomBarOuterPadding),
horizontal = BottomBarOuterPaddingH, horizontalArrangement = Arrangement.SpaceBetween,
vertical = BottomBarOuterPaddingV,
),
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
config.slots.forEach { slot -> config.slots.forEach { slot ->
@@ -89,6 +90,7 @@ fun F7MobileBottomBar(
"$base/themes/forbion/images/header/chat-icon-gray.svg" "$base/themes/forbion/images/header/chat-icon-gray.svg"
}, },
contentDescription = "Чаты", contentDescription = "Чаты",
active = chatsHighlighted,
onClick = actions.onChatsClick, onClick = actions.onChatsClick,
) )
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot( F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
@@ -101,18 +103,21 @@ fun F7MobileBottomBar(
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg", iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg",
contentDescription = "Почта", contentDescription = "Почта",
iconSize = BottomBarSectionIconSize, iconSize = BottomBarSectionIconSize,
active = activeTabKey.equals("mail", ignoreCase = true),
onClick = actions.onMailClick, onClick = actions.onMailClick,
) )
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot( F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg", iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg",
contentDescription = "Карточки", contentDescription = "Карточки",
iconSize = BottomBarSectionIconSize, iconSize = BottomBarSectionIconSize,
active = activeTabKey.equals("deck", ignoreCase = true),
onClick = actions.onCardsClick, onClick = actions.onCardsClick,
) )
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot( F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg", iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg",
contentDescription = "Конференции", contentDescription = "Конференции",
iconSize = BottomBarSectionIconSize, iconSize = BottomBarSectionIconSize,
active = activeTabKey.equals("talk", ignoreCase = true),
onClick = actions.onConferencesClick, onClick = actions.onConferencesClick,
) )
F7BottomBarSlot.Menu -> F7BottomBarIconSlot( F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
@@ -122,6 +127,7 @@ fun F7MobileBottomBar(
"$base/themes/forbion/images/header/menu-burger-gray.svg" "$base/themes/forbion/images/header/menu-burger-gray.svg"
}, },
contentDescription = "Меню", contentDescription = "Меню",
active = menuOpen,
showBadge = showNotificationBadge, showBadge = showNotificationBadge,
onClick = actions.onMenuClick, onClick = actions.onMenuClick,
) )
@@ -135,19 +141,27 @@ private fun F7BottomBarIconSlot(
iconUrl: String, iconUrl: String,
contentDescription: String, contentDescription: String,
onClick: () -> Unit, onClick: () -> Unit,
active: Boolean = false,
iconRotation: Float = 0f, iconRotation: Float = 0f,
showBadge: Boolean = false, showBadge: Boolean = false,
iconSize: Dp = BottomBarIconSize, iconSize: Dp = BottomBarIconSize,
) { ) {
// Зелёной подложки под активной кнопкой быть не должно — фон всегда нейтральный. // Живой forbion: кнопка 55, фон #FBFBFB + рамка #E6E6E6; активная (текущий раздел /
// открытый бургер) — полупрозрачный зелёный градиент 104°.
Box( Box(
modifier = Modifier modifier = Modifier
.size(BottomBarButtonSize) .size(BottomBarButtonSize)
.clip(BottomBarButtonShape) .clip(BottomBarButtonShape)
.background(Color(0x99FFFFFF), BottomBarButtonShape) .then(
if (active) {
Modifier.background(BottomBarActiveBrush, BottomBarButtonShape)
} else {
Modifier.background(BottomBarButtonBg, BottomBarButtonShape)
},
)
.border( .border(
width = 1.dp, width = 1.dp,
brush = BottomBarBorderBrush, color = BottomBarButtonBorder,
shape = BottomBarButtonShape, shape = BottomBarButtonShape,
) )
.clickable( .clickable(
@@ -0,0 +1,260 @@
package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DeckCardSheet(
card: DeckCard,
boardLabels: List<DeckLabel>,
stacks: List<DeckStack>,
canEdit: Boolean,
busy: Boolean,
onDismiss: () -> Unit,
onToggleDone: () -> Unit,
onSave: (title: String, description: String, duedate: String?) -> Unit,
onToggleLabel: (DeckLabel) -> Unit,
onMove: (targetStackId: Int) -> Unit,
onArchive: () -> Unit,
onDelete: () -> Unit,
) {
var title by remember(card.id) { mutableStateOf(card.title) }
var description by remember(card.id) { mutableStateOf(card.description) }
// duedate храним как ISO-строку (как отдаёт/принимает Deck), null = без срока
var duedate by remember(card.id) { mutableStateOf(card.duedate) }
var datePickerOpen by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(
modifier = Modifier.fillMaxWidth(0.96f).heightIn(max = 680.dp),
shape = RoundedCornerShape(16.dp),
color = Color.White,
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Done + заголовок
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Checkbox(
checked = card.done,
onCheckedChange = { if (canEdit) onToggleDone() },
enabled = canEdit && !busy,
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Text(
if (card.done) "Выполнена" else "Не выполнена",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
F7OutlinedField(value = title, onValueChange = { title = it }, label = "Название")
F7OutlinedField(value = description, onValueChange = { description = it }, label = "Описание", minLines = 3)
// Срок
Text("Срок", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.SurfaceMuted)
.clickable(enabled = canEdit) { datePickerOpen = true }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
duedate?.let { formatDeckDue(it) } ?: "Без срока",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = if (duedate != null) F7Colors.TextPrimary else F7Colors.TextSecondary,
)
if (duedate != null) {
Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })
}
}
// Метки доски
if (boardLabels.isNotEmpty()) {
Text("Метки", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
boardLabels.forEach { label ->
val active = card.labels.any { it.id == label.id }
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(
(parseDeckColor(label.color) ?: F7Colors.Primary)
.copy(alpha = if (active) 0.35f else 0.12f),
)
.border(
if (active) 2.dp else 1.dp,
parseDeckColor(label.color) ?: F7Colors.Primary,
RoundedCornerShape(4.dp),
)
.clickable(enabled = canEdit && !busy) { onToggleLabel(label) }
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(label.title, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextPrimary)
}
}
}
}
// Переместить в колонку
if (canEdit && stacks.size > 1) {
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
stacks.forEach { stack ->
val current = stack.id == card.stackId
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(if (current) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
.clickable(enabled = !current && !busy) { onMove(stack.id) }
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
Text(stack.title, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
}
}
if (canEdit) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(
text = if (busy) "" else "Сохранить",
onClick = { onSave(title, description, duedate) },
enabled = !busy && title.isNotBlank(),
modifier = Modifier.weight(1f),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("В архив", onClick = onArchive, enabled = !busy, modifier = Modifier.weight(1f))
F7SecondaryButton("Удалить", onClick = onDelete, enabled = !busy, modifier = Modifier.weight(1f))
}
} else {
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
}
}
}
}
if (datePickerOpen) {
val initMillis = duedate?.let { parseDeckDueMillis(it) }
?: System.currentTimeMillis()
val pickerState = rememberDatePickerState(initialSelectedDateMillis = initMillis)
DatePickerDialog(
onDismissRequest = { datePickerOpen = false },
confirmButton = {
TextButton(onClick = {
pickerState.selectedDateMillis?.let { millis ->
// Deck принимает ISO-8601 (в полдень UTC достаточно для «дня»)
val instant = Instant.ofEpochMilli(millis)
duedate = DateTimeFormatter.ISO_INSTANT.format(instant)
}
datePickerOpen = false
}) { Text("ОК", color = F7Colors.Primary) }
},
dismissButton = {
TextButton(onClick = { datePickerOpen = false }) { Text("Отмена", color = F7Colors.TextSecondary) }
},
) { DatePicker(state = pickerState) }
}
}
@Composable
fun DeckTextPromptDialog(
title: String,
label: String,
confirmText: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var text by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
F7OutlinedField(value = text, onValueChange = { text = it }, label = label)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(
text = confirmText,
onClick = { onConfirm(text) },
enabled = text.isNotBlank(),
modifier = Modifier.weight(1f),
)
}
}
}
}
}
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
Instant.parse(iso).toEpochMilli()
}.getOrElse {
runCatching {
java.time.OffsetDateTime.parse(iso).toInstant().toEpochMilli()
}.getOrNull()
}
/** Отображение срока: «12 мар». */
internal fun formatDeckDue(iso: String): String {
val millis = parseDeckDueMillis(iso) ?: return iso.take(10)
val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
return date.format(DateTimeFormatter.ofPattern("d MMM"))
}
@@ -1,91 +1,220 @@
package ru.forbion.f7cloud.feature.deck package ru.forbion.f7cloud.feature.deck
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
/**
* Deck через внутренний фронт-API (`/index.php/apps/deck/api/v1.0`) тот же, что у веб-версии.
* Пишущие запросы авторизуются app-password + OCS-APIRequest (CSRF при app-password не требуется).
*/
class DeckRepository { class DeckRepository {
private fun apiBase(session: AuthSession): String { private fun apiBase(session: AuthSession): String =
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0" "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
}
private fun client(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
// --- Чтение ---
suspend fun loadBoards(session: AuthSession): List<DeckBoard> { suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) val json = getJson(client(session), "${apiBase(session)}/boards")
val json = getJson(client, "${apiBase(session)}/boards") val array = json as? JSONArray ?: (json as? JSONObject)?.let { JSONArray().put(it) } ?: JSONArray()
val array = when (json) {
is JSONArray -> json
is JSONObject -> JSONArray().put(json)
else -> JSONArray()
}
val out = mutableListOf<DeckBoard>() val out = mutableListOf<DeckBoard>()
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val board = array.optJSONObject(i) ?: continue val board = array.optJSONObject(i) ?: continue
val id = board.optInt("id", 0) val id = board.optInt("id", 0)
val title = board.optString("title") if (id > 0 && !board.optBoolean("archived", false)) {
if (id > 0 && title.isNotBlank()) { out += DeckBoard(
out += DeckBoard(id = id, title = title, color = board.optString("color")) id = id,
title = board.optString("title"),
color = board.optString("color"),
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true,
)
} }
} }
return out return out
} }
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail { suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) val json = getJson(client(session), "${apiBase(session)}/cards/$cardId") as JSONObject
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
val boardId = json.optInt("boardId", json.optInt("board_id", 0)) val boardId = json.optInt("boardId", json.optInt("board_id", 0))
if (boardId <= 0) error("Карточка не найдена") if (boardId <= 0) error("Карточка не найдена")
return DeckCardDetail( return DeckCardDetail(cardId = cardId, boardId = boardId, title = json.optString("title"))
cardId = cardId,
boardId = boardId,
title = json.optString("title"),
)
} }
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail { suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts) val c = client(session)
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks") val board = getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject
val stacksArray = when (stacksJson) { val labels = parseLabels(board.optJSONArray("labels"))
is JSONArray -> stacksJson val canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
else -> JSONArray() val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
}
val stacks = mutableListOf<DeckStack>() val stacks = mutableListOf<DeckStack>()
for (i in 0 until stacksArray.length()) { for (i in 0 until stacksJson.length()) {
val stack = stacksArray.optJSONObject(i) ?: continue val stack = stacksJson.optJSONObject(i) ?: continue
val stackId = stack.optInt("id", 0) val stackId = stack.optInt("id", 0)
val title = stack.optString("title") if (stackId <= 0) continue
val cards = mutableListOf<DeckCard>()
val cardsArray = stack.optJSONArray("cards") ?: JSONArray() val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
for (c in 0 until cardsArray.length()) { val cards = mutableListOf<DeckCard>()
val card = cardsArray.optJSONObject(c) ?: continue for (cIdx in 0 until cardsArray.length()) {
val cardTitle = card.optString("title") cardsArray.optJSONObject(cIdx)?.let { cards += parseCard(it, stackId) }
if (cardTitle.isNotBlank()) {
cards += DeckCard(
id = card.optInt("id", 0),
title = cardTitle,
done = card.has("done") && !card.isNull("done"),
)
}
}
if (stackId > 0) {
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
} }
stacks += DeckStack(
id = stackId,
boardId = boardId,
title = stack.optString("title").ifBlank { "Колонка" },
order = stack.optInt("order", i),
cards = cards.sortedBy { it.order },
)
} }
return DeckBoardDetail(boardId = boardId, stacks = stacks) return DeckBoardDetail(
boardId = boardId,
title = board.optString("title"),
canEdit = canEdit,
labels = labels,
stacks = stacks.sortedBy { it.order },
)
} }
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any { private fun parseCard(card: JSONObject, stackId: Int): DeckCard = DeckCard(
id = card.optInt("id", 0),
title = card.optString("title"),
done = !card.isNull("done") && card.optString("done").isNotBlank(),
description = card.optString("description"),
duedate = card.optString("duedate").takeIf { it.isNotBlank() && it != "null" },
order = card.optInt("order", 0),
stackId = stackId,
labels = parseLabels(card.optJSONArray("labels")),
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
)
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
if (array == null) return emptyList()
val out = mutableListOf<DeckLabel>()
for (i in 0 until array.length()) {
val l = array.optJSONObject(i) ?: continue
val id = l.optInt("id", 0)
if (id > 0) out += DeckLabel(id = id, title = l.optString("title"), color = l.optString("color"))
}
return out
}
private fun parseAssignees(array: JSONArray?): List<String> {
if (array == null) return emptyList()
val out = mutableListOf<String>()
for (i in 0 until array.length()) {
val a = array.optJSONObject(i) ?: continue
val p = a.optJSONObject("participant")
val name = p?.optString("displayname")?.takeIf { it.isNotBlank() }
?: p?.optString("uid")?.takeIf { it.isNotBlank() }
?: a.optString("displayname")
if (name.isNotBlank()) out += name
}
return out
}
// --- Запись ---
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
val body = JSONObject().put("title", title).put("type", "plain").put("order", 999).put("stackId", stackId)
val json = sendJson(session, "POST", "${apiBase(session)}/cards", body) as? JSONObject
return json?.let { parseCard(it, stackId) }
?: DeckCard(id = 0, title = title, done = false, stackId = stackId)
}
fun updateCard(
session: AuthSession,
card: DeckCard,
title: String,
description: String,
duedate: String?,
) {
val body = JSONObject()
.put("title", title)
.put("type", "plain")
.put("owner", session.username)
.put("description", description)
.put("order", card.order)
.put("stackId", card.stackId)
.put("duedate", duedate ?: JSONObject.NULL)
sendJson(session, "PUT", "${apiBase(session)}/cards/${card.id}", body)
}
fun setCardDone(session: AuthSession, cardId: Int, done: Boolean) {
val path = if (done) "done" else "undone"
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/$path", JSONObject())
}
fun moveCard(session: AuthSession, cardId: Int, targetStackId: Int, order: Int = 0) {
val body = JSONObject().put("stackId", targetStackId).put("order", order)
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/reorder", body)
}
fun archiveCard(session: AuthSession, cardId: Int) {
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/archive", JSONObject())
}
fun deleteCard(session: AuthSession, cardId: Int) {
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId", null)
}
fun assignLabel(session: AuthSession, cardId: Int, labelId: Int) {
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/label/$labelId", JSONObject())
}
fun removeLabel(session: AuthSession, cardId: Int, labelId: Int) {
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
}
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
val id = json?.optInt("id", 0) ?: 0
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList())
}
// --- HTTP ---
private fun getJson(client: OkHttpClient, url: String): Any {
// GET — как в прежней рабочей версии (без доп. заголовков); запись отдельно в sendJson.
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) error("Deck API HTTP ${response.code}")
error("Deck API HTTP ${response.code}")
}
val body = response.body!!.string().trim() val body = response.body!!.string().trim()
if (body.startsWith("[")) return JSONArray(body) return when {
if (body.startsWith("{")) return JSONObject(body) body.startsWith("[") -> JSONArray(body)
return JSONArray() body.startsWith("{") -> JSONObject(body)
else -> JSONArray()
}
}
}
private val jsonMedia = "application/json".toMediaType()
private fun sendJson(session: AuthSession, method: String, url: String, body: JSONObject?): Any? {
val payload = (body?.toString() ?: "{}").toRequestBody(jsonMedia)
val builder = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
when (method) {
"POST" -> builder.post(payload)
"PUT" -> builder.put(payload)
"DELETE" -> if (body != null) builder.delete(payload) else builder.delete()
}
client(session).newCall(builder.build()).execute().use { response ->
if (!response.isSuccessful) error("Deck API HTTP ${response.code}")
val text = response.body?.string()?.trim().orEmpty()
return when {
text.startsWith("{") -> JSONObject(text)
text.startsWith("[") -> JSONArray(text)
else -> null
}
} }
} }
} }
@@ -94,11 +223,20 @@ data class DeckBoard(
val id: Int, val id: Int,
val title: String, val title: String,
val color: String, val color: String,
val canEdit: Boolean = true,
)
data class DeckLabel(
val id: Int,
val title: String,
val color: String,
) )
data class DeckStack( data class DeckStack(
val id: Int, val id: Int,
val boardId: Int,
val title: String, val title: String,
val order: Int,
val cards: List<DeckCard>, val cards: List<DeckCard>,
) )
@@ -106,10 +244,19 @@ data class DeckCard(
val id: Int, val id: Int,
val title: String, val title: String,
val done: Boolean, val done: Boolean,
val description: String = "",
val duedate: String? = null,
val order: Int = 0,
val stackId: Int = 0,
val labels: List<DeckLabel> = emptyList(),
val assignees: List<String> = emptyList(),
) )
data class DeckBoardDetail( data class DeckBoardDetail(
val boardId: Int, val boardId: Int,
val title: String,
val canEdit: Boolean,
val labels: List<DeckLabel>,
val stacks: List<DeckStack>, val stacks: List<DeckStack>,
) )
@@ -1,26 +1,55 @@
package ru.forbion.f7cloud.feature.deck package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun DeckScreen( fun DeckScreen(
session: AuthSession, session: AuthSession,
@@ -31,23 +60,18 @@ fun DeckScreen(
) { ) {
val vm: DeckViewModel = viewModel() val vm: DeckViewModel = viewModel()
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
var addStackOpen by remember { mutableStateOf(false) }
var addCardStackId by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(session.serverUrl, session.username) { LaunchedEffect(session.serverUrl, session.username) { vm.load(session) }
vm.load(session)
}
LaunchedEffect(openCardId) { LaunchedEffect(openCardId) {
val cardId = openCardId ?: return@LaunchedEffect val cardId = openCardId ?: return@LaunchedEffect
vm.openCardById(session, cardId) vm.openCardById(session, cardId)
onOpenCardConsumed() onOpenCardConsumed()
} }
LaunchedEffect(state.unauthorized) { LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() }
if (state.unauthorized) onUnauthorized()
}
F7OverlayDismissHandler( F7OverlayDismissHandler(enabled = state.boardDetail != null, onDismiss = vm::closeBoard)
enabled = state.boardDetail != null,
onDismiss = vm::closeBoard,
)
F7ModuleScreen( F7ModuleScreen(
modifier = modifier, modifier = modifier,
@@ -55,7 +79,12 @@ fun DeckScreen(
error = state.error, error = state.error,
onRefresh = { vm.load(session) }, onRefresh = { vm.load(session) },
headerActions = { headerActions = {
if (state.selectedBoardId != null) { val detail = state.boardDetail
if (detail != null) {
if (detail.canEdit) {
F7SecondaryButton(text = "+ Колонка", onClick = { addStackOpen = true })
Spacer(Modifier.size(8.dp))
}
F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() }) F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() })
} }
}, },
@@ -66,31 +95,240 @@ fun DeckScreen(
onRefresh = { onRefresh = {
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session) if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
}, },
modifier = Modifier modifier = Modifier.weight(1f).fillMaxWidth(),
.weight(1f)
.fillMaxWidth(),
) { ) {
if (detail != null) { if (detail != null) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(detail.stacks, key = { it.id }) { stack -> items(detail.stacks, key = { it.id }) { stack ->
F7ListCard { DeckStackSection(
Text(stack.title, style = MaterialTheme.typography.titleSmall) stack = stack,
stack.cards.forEach { card -> canEdit = detail.canEdit,
val prefix = if (card.done) "" else "" onCardClick = { vm.openCard(it) },
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium) onToggleDone = { vm.toggleDone(session, it) },
} onAddCard = { addCardStackId = stack.id },
} )
} }
} }
} else { } else {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.boards, key = { it.id }) { board -> items(state.boards, key = { it.id }) { board ->
F7ListCard(onClick = { vm.openBoard(session, board.id) }) { DeckBoardRow(board = board, onClick = { vm.openBoard(session, board.id) })
Text(board.title, style = MaterialTheme.typography.titleSmall)
}
} }
} }
} }
} }
} }
// Карточка-деталь
state.openedCard?.let { card ->
DeckCardSheet(
card = card,
boardLabels = state.boardDetail?.labels.orEmpty(),
stacks = state.boardDetail?.stacks.orEmpty(),
canEdit = state.boardDetail?.canEdit ?: false,
busy = state.busy,
onDismiss = vm::closeCard,
onToggleDone = { vm.toggleDone(session, card) },
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
onToggleLabel = { vm.toggleLabel(session, card, it) },
onMove = { vm.moveCard(session, card, it) },
onArchive = { vm.archiveCard(session, card) },
onDelete = { vm.deleteCard(session, card) },
)
}
if (addStackOpen) {
DeckTextPromptDialog(
title = "Новая колонка",
label = "Название колонки",
confirmText = "Создать",
onConfirm = { vm.createStack(session, it); addStackOpen = false },
onDismiss = { addStackOpen = false },
)
}
addCardStackId?.let { stackId ->
DeckTextPromptDialog(
title = "Новая карточка",
label = "Название карточки",
confirmText = "Создать",
onConfirm = { vm.createCard(session, stackId, it); addCardStackId = null },
onDismiss = { addCardStackId = null },
)
}
}
@Composable
private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFFDFDFD))
.border(1.dp, Color(0xFFF0F1F4), RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(parseDeckColor(board.color) ?: F7Colors.Primary),
)
Text(
board.title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DeckStackSection(
stack: DeckStack,
canEdit: Boolean,
onCardClick: (DeckCard) -> Unit,
onToggleDone: (DeckCard) -> Unit,
onAddCard: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(F7Colors.Grey2)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stack.title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Text(
stack.cards.size.toString(),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
if (canEdit) {
Spacer(Modifier.size(8.dp))
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight)
.clickable(onClick = onAddCard),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))
}
}
}
stack.cards.forEach { card ->
DeckCardRow(card = card, onClick = { onCardClick(card) }, onToggleDone = { onToggleDone(card) })
}
if (stack.cards.isEmpty()) {
Text("Нет карточек", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFFDFDFD))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (card.labels.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
card.labels.forEach { DeckLabelChip(it) }
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.border(1.5.dp, if (card.done) F7Colors.Primary else F7Colors.Grey1, RoundedCornerShape(4.dp))
.background(if (card.done) F7Colors.Primary else Color.Transparent)
.clickable(onClick = onToggleDone),
contentAlignment = Alignment.Center,
) {
if (card.done) Text("", color = Color.White, style = MaterialTheme.typography.labelSmall)
}
Spacer(Modifier.size(8.dp))
Text(
card.title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
val due = card.duedate?.let { formatDeckDue(it) }
if (due != null || card.assignees.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 28.dp),
) {
if (due != null) {
Text("🗓 $due", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
}
if (card.assignees.isNotEmpty()) {
Text("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@Composable
internal fun DeckLabelChip(label: DeckLabel) {
val bg = parseDeckColor(label.color) ?: F7Colors.Primary
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(bg.copy(alpha = 0.22f))
.border(1.dp, bg, RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 1.dp),
) {
Text(
label.title,
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** Цвет Deck («RRGGBB» без #, иногда с #). */
internal fun parseDeckColor(hex: String?): Color? {
val h = hex?.trim()?.removePrefix("#")?.takeIf { it.length == 6 } ?: return null
return runCatching { Color(("FF$h").toLong(16)) }.getOrNull()
} }
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException import ru.forbion.f7cloud.core.network.UnauthorizedException
@@ -15,6 +16,8 @@ data class DeckUiState(
val boards: List<DeckBoard> = emptyList(), val boards: List<DeckBoard> = emptyList(),
val selectedBoardId: Int? = null, val selectedBoardId: Int? = null,
val boardDetail: DeckBoardDetail? = null, val boardDetail: DeckBoardDetail? = null,
val openedCard: DeckCard? = null,
val busy: Boolean = false,
val error: String? = null, val error: String? = null,
val unauthorized: Boolean = false, val unauthorized: Boolean = false,
) )
@@ -27,61 +30,116 @@ class DeckViewModel(
fun load(session: AuthSession) { fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null) _state.update { it.copy(loading = true, error = null) }
runCatching { repository.loadBoards(session) } runCatching { repository.loadBoards(session) }
.onSuccess { boards -> .onSuccess { boards -> _state.update { it.copy(loading = false, boards = boards, error = null) } }
_state.value = _state.value.copy(loading = false, boards = boards, error = null) .onFailure { t -> fail(t) }
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
} }
} }
fun openBoard(session: AuthSession, boardId: Int) { fun openBoard(session: AuthSession, boardId: Int) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy( _state.update { it.copy(loading = true, selectedBoardId = boardId, boardDetail = null, error = null) }
loading = true, runCatching { repository.loadBoardDetail(session, boardId) }
selectedBoardId = boardId, .onSuccess { detail -> _state.update { it.copy(loading = false, boardDetail = detail) } }
boardDetail = null, .onFailure { t -> fail(t) }
error = null, }
) }
private fun reloadBoard(session: AuthSession) {
val boardId = _state.value.selectedBoardId ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadBoardDetail(session, boardId) } runCatching { repository.loadBoardDetail(session, boardId) }
.onSuccess { detail -> .onSuccess { detail ->
_state.value = _state.value.copy(loading = false, boardDetail = detail) // если открыта карточка — подставим её свежую версию
} val openedId = _state.value.openedCard?.id
.onFailure { t -> val freshCard = openedId?.let { id ->
_state.value = _state.value.copy( detail.stacks.flatMap { it.cards }.firstOrNull { it.id == id }
loading = false, }
error = t.message, _state.update { it.copy(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false) }
unauthorized = t is UnauthorizedException,
)
} }
.onFailure { t -> fail(t) }
} }
} }
fun closeBoard() { fun closeBoard() {
_state.value = _state.value.copy(selectedBoardId = null, boardDetail = null) _state.update { it.copy(selectedBoardId = null, boardDetail = null, openedCard = null) }
}
fun openCard(card: DeckCard) {
_state.update { it.copy(openedCard = card) }
}
fun closeCard() {
_state.update { it.copy(openedCard = null) }
} }
fun openCardById(session: AuthSession, cardId: Int) { fun openCardById(session: AuthSession, cardId: Int) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null) _state.update { it.copy(loading = true, error = null) }
runCatching { repository.loadCard(session, cardId) } runCatching { repository.loadCard(session, cardId) }
.onSuccess { card -> .onSuccess { card -> openBoard(session, card.boardId) }
openBoard(session, card.boardId) .onFailure { t -> fail(t, "Не удалось открыть карточку") }
} }
.onFailure { t -> }
_state.value = _state.value.copy(
loading = false, // --- Действия (оптимистично не мутируем, перечитываем доску) ---
error = t.message ?: "Не удалось открыть карточку",
unauthorized = t is UnauthorizedException, fun createCard(session: AuthSession, stackId: Int, title: String) =
) mutate(session) { repository.createCard(session, stackId, title.trim()) }
fun toggleDone(session: AuthSession, card: DeckCard) =
mutate(session) { repository.setCardDone(session, card.id, !card.done) }
fun updateCard(session: AuthSession, card: DeckCard, title: String, description: String, duedate: String?) =
mutate(session) { repository.updateCard(session, card, title.trim(), description.trim(), duedate) }
fun moveCard(session: AuthSession, card: DeckCard, targetStackId: Int) =
mutate(session) { repository.moveCard(session, card.id, targetStackId) }
fun archiveCard(session: AuthSession, card: DeckCard) =
mutate(session, closeCardAfter = true) { repository.archiveCard(session, card.id) }
fun deleteCard(session: AuthSession, card: DeckCard) =
mutate(session, closeCardAfter = true) { repository.deleteCard(session, card.id) }
fun toggleLabel(session: AuthSession, card: DeckCard, label: DeckLabel) = mutate(session) {
if (card.labels.any { it.id == label.id }) {
repository.removeLabel(session, card.id, label.id)
} else {
repository.assignLabel(session, card.id, label.id)
}
}
fun createStack(session: AuthSession, title: String) {
val boardId = _state.value.selectedBoardId ?: return
mutate(session) { repository.createStack(session, boardId, title.trim()) }
}
private fun mutate(
session: AuthSession,
closeCardAfter: Boolean = false,
action: () -> Unit,
) {
_state.update { it.copy(busy = true, error = null) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { action() }
.onSuccess {
if (closeCardAfter) _state.update { it.copy(openedCard = null) }
reloadBoard(session)
} }
.onFailure { t -> fail(t) }
}
}
private fun fail(t: Throwable, fallback: String? = null) {
_state.update {
it.copy(
loading = false,
busy = false,
error = t.message ?: fallback,
unauthorized = t is UnauthorizedException,
)
} }
} }
} }