18 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 18:33:04 +00:00
40 changed files with 2109 additions and 395 deletions
+2
View File
@@ -2,6 +2,8 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.1 — контакты): инкрементальная синхронизация по CTag. `CardDavClient.collectionSignature()` — дешёвый PROPFIND depth:1 за CTag адресных книг (расширение CalendarServer, поддерживается Nextcloud; фолбэк на sync-token), без address-data. `ContactsRepository.syncContacts` перед полной закачкой всех vCard сверяет подпись коллекций: не менялось → отдаём кэш (Room), сеть не грузим. Ускоряет pull-to-refresh (было — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста (regex-парсер подписи, без Android XmlPull). Дальше по Этапу 2: кэш событий календаря + CTag (сейчас экран календаря тянет события живьём по диапазону — под него сначала нужен кэш) | контракты не менялись | низкий: только оптимизация, поведение то же при изменениях
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26
targetSdk 36
versionCode 132
versionName '0.5.124'
versionCode 134
versionName '0.5.126'
missingDimensionStrategy 'default', 'f7'
multiDexEnabled true
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

@@ -3,6 +3,8 @@ package ru.forbion.f7cloud.mobile.ui
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,9 +33,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -148,13 +152,15 @@ internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
}
}
val logoUrl = remember(serverUrl) {
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
}
// Логотип — локальный (assets/login/big-forbion.svg из живой темы): виден офлайн и до
// ввода адреса сервера.
val logoUrl = "file:///android_asset/login/big-forbion.svg"
Box(
modifier = Modifier
.fillMaxSize()
// Живая тема логина: фон страницы --backgroud-color-main-darkgray
.background(Color(0xFFE5EFE8))
.f7SafeTopInsets()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 24.dp),
@@ -269,12 +275,23 @@ internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
)
F7TextButton(
text = "Сканировать QR для входа",
onClick = { launchQrScan() },
enabled = !loading,
modifier = Modifier.fillMaxWidth(),
)
// Живая тема: вторичная кнопка-ссылка 40dp, фон #F0F1F4, r8, текст 14 чёрный
Box(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFF0F1F4))
.clickable(enabled = !loading, onClick = { launchQrScan() }),
contentAlignment = Alignment.Center,
) {
Text(
"Сканировать QR для входа",
fontSize = 14.sp,
lineHeight = 20.sp,
color = F7Colors.TextPrimary,
)
}
if (loading) {
CircularProgressIndicator(color = F7Colors.Primary)
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Логотип F7 для сплэша: вектор из темы forbion (logo-header.svg) вместо растянутого
растра лаунчер-иконки (мыло). Глиф (35×32, контент x 2.2633.53 / y 5.0125.99)
отцентрирован в центральных 2/3 квадрата 48 — безопасная зона круглой маски сплэша. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="48"
android:viewportHeight="48">
<group
android:scaleX="1.0233"
android:scaleY="1.0233"
android:translateX="5.69"
android:translateY="8.14">
<path
android:fillColor="#151515"
android:pathData="M8.33398 18.9808V25.9711C8.33398 25.9761 8.33209 25.981 8.32873 25.9845C8.32537 25.9881 8.3208 25.9901 8.31605 25.9901H2.27416C2.2718 25.9901 2.26947 25.9896 2.2673 25.9887C2.26512 25.9877 2.26314 25.9863 2.26148 25.9845C2.25812 25.981 2.25623 25.9761 2.25623 25.9711V5.02973C2.25623 5.02724 2.25669 5.02477 2.25759 5.02246C2.25849 5.02016 2.25981 5.01807 2.26148 5.0163C2.26484 5.01274 2.2694 5.01074 2.27416 5.01074H19.7382C19.7415 5.01074 19.7447 5.01166 19.7475 5.01343C19.7503 5.0152 19.7526 5.01773 19.7541 5.02076C19.7556 5.0238 19.7564 5.02722 19.7562 5.03065C19.756 5.03409 19.755 5.03742 19.7532 5.04028L16.2595 10.7647C16.2578 10.7674 16.2555 10.7696 16.2528 10.771C16.2501 10.7725 16.2471 10.7732 16.244 10.7731H8.35192C8.34716 10.7731 8.3426 10.7751 8.33924 10.7787C8.33587 10.7823 8.33398 10.7871 8.33398 10.7921V13.1672C8.33398 13.1723 8.33587 13.1771 8.33924 13.1807C8.3426 13.1842 8.34716 13.1862 8.35192 13.1862H14.9303C14.9328 13.1862 14.9352 13.1867 14.9374 13.1878C14.9397 13.1888 14.9417 13.1903 14.9434 13.1922C14.9451 13.194 14.9464 13.1963 14.9472 13.1987C14.948 13.2011 14.9484 13.2037 14.9483 13.2063L14.6937 18.9439C14.6935 18.9487 14.6915 18.9533 14.6881 18.9566C14.6848 18.96 14.6804 18.9618 14.6758 18.9618H8.35192C8.34716 18.9618 8.3426 18.9638 8.33924 18.9674C8.33587 18.9709 8.33398 18.9758 8.33398 18.9808Z" />
<path
android:fillColor="#70B62B"
android:pathData="M26.2675 10.8048C26.2877 10.7837 26.2836 10.7731 26.255 10.7731H17.7975C17.7762 10.7731 17.7714 10.7638 17.783 10.7452L21.3221 5.03395C21.3317 5.01848 21.3453 5.01074 21.3629 5.01074H33.5155C33.5188 5.01074 33.5219 5.01213 33.5243 5.0146C33.5266 5.01708 33.5279 5.02043 33.5279 5.02393V10.7494C33.5279 10.7673 33.5218 10.7826 33.5095 10.7953C31.0839 13.3392 28.8027 16.2518 27.1169 19.4365C26.3193 20.944 25.6677 22.6434 25.4834 24.3571C25.4296 24.8592 25.4445 25.4268 25.445 25.9632C25.445 25.9811 25.4367 25.9901 25.4201 25.9901H18.4337C18.4164 25.9901 18.4072 25.981 18.4063 25.9627C18.3571 24.9535 18.4315 23.9955 18.6294 23.0886C19.0753 21.0468 20.0567 19.0346 21.1572 17.2724C22.6248 14.9221 24.3809 12.7806 26.2675 10.8048Z" />
</group>
</vector>
+3 -2
View File
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Сплэш при холодном старте (androidx core-splashscreen): фон в цвет
F7Colors.Background + иконка приложения; после — прежняя тема. -->
F7Colors.Background + векторный логотип F7 (лаунчер-иконка — растянутый
растр, мылится на сплэше); после — прежняя тема. -->
<style name="Theme.F7.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#FBFBFB</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/f7_splash_logo</item>
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
</style>
</resources>
@@ -42,9 +42,11 @@ object F7Colors {
val SecondaryButtonBg = Color(0xFFFDFDFD)
val SecondaryButtonBorder = Grey3
// Дизайн-красный #FF7A66 (был #D74642 — расходился с макетом)
val Error = Red
val ErrorBg = Red10
// Семантика ошибок — по боевой теме forbion (_base.css: --color-element-error /
// --color-text-error / --color-error); базовый Red #FF7A66 из брендбука остаётся для тегов.
val Error = Color(0xFFD74642)
val ErrorText = Color(0xFFD42722)
val ErrorBg = Color(0xFFFFE2E2)
val StatusNew = Color(0xFF2B9AB6)
val StatusProgress = Color(0xFF70B62B)
@@ -392,54 +392,58 @@ fun CalendarMonthGrid(
) {
val days = CalendarRepository.monthGridDays(month)
val today = LocalDate.now()
// Живая тема (_calendar-month-view-mobile.css): месяц — белая карточка r12 с рамкой,
// внутри дни недели — круги 40 (#F5F5F5, 16/500) и ячейки-карточки #FDFDFD r4
// с тенью, зазор 4.
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Заголовок дней недели — серые пилюли (по макету «Календарь · Месяц»)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
weekDayLabels.forEach { label ->
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Grey2)
.padding(vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextSecondary,
)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(F7Colors.Grey2),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
)
}
}
}
}
// Гридлайн-сетка без скруглений (по макету)
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, F7Colors.Grey3),
) {
days.chunked(7).forEach { week ->
Row(modifier = Modifier.fillMaxWidth()) {
week.forEach { day ->
val inMonth = day.month == month.month
val dayEvents = eventsByDay[day].orEmpty()
CalendarDayCell(
day = day.dayOfMonth,
inMonth = inMonth,
isToday = day == today,
selected = day == selectedDay,
events = dayEvents,
onClick = { onSelectDay(day) },
modifier = Modifier.weight(1f),
)
}
days.chunked(7).forEach { week ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
week.forEach { day ->
val inMonth = day.month == month.month
val dayEvents = eventsByDay[day].orEmpty()
CalendarDayCell(
day = day.dayOfMonth,
inMonth = inMonth,
isToday = day == today,
selected = day == selectedDay,
events = dayEvents,
onClick = { onSelectDay(day) },
modifier = Modifier.weight(1f),
)
}
}
}
@@ -456,65 +460,79 @@ private fun CalendarDayCell(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val cellBg = when {
isToday -> F7Colors.Green10
selected -> F7Colors.PrimaryLight
else -> F7Colors.Surface
}
// Живая тема: ячейка — карточка #FDFDFD r4 с тонкой тенью, высота 120, паддинг 4;
// номер дня 14/500 СПРАВА-сверху (чужой месяц — серый); «сегодня» — зелёная плашка
// 40×24 r4 с белым числом; выбранный день подсвечиваем #ECF9DE (наша адаптация тапа).
Column(
modifier = modifier
.heightIn(min = 66.dp)
.border(0.5.dp, F7Colors.Grey3)
.background(cellBg)
.height(120.dp)
.shadow(
elevation = 1.dp,
shape = RoundedCornerShape(4.dp),
spotColor = F7Colors.Border,
)
.clip(RoundedCornerShape(4.dp))
.background(if (selected) F7Colors.PrimaryLight else Color(0xFFFDFDFD))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 4.dp),
.padding(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// Номер дня сверху-слева; сегодня — в зелёном круге
Box(
modifier = Modifier
.size(22.dp)
.then(if (isToday) Modifier.clip(CircleShape).background(F7Colors.Primary) else Modifier),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (isToday) FontWeight.SemiBold else FontWeight.Normal,
color = when {
isToday -> F7Colors.TextOnPrimary
!inMonth -> F7Colors.TextMuted
else -> F7Colors.TextPrimary
},
)
}
if (inMonth) {
events.take(2).forEach { event ->
CalendarDayEventPill(event.summary.ifBlank { "Событие" })
}
if (events.size > 2) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
if (isToday) {
Box(
modifier = Modifier
.width(40.dp)
.height(24.dp)
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.Primary),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = Color.White,
)
}
} else {
Text(
"+${events.size - 2}",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
text = day.toString(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (inMonth) F7Colors.TextPrimary else F7Colors.TextSecondary,
)
}
}
events.take(3).forEach { event ->
CalendarDayEventPill(event.summary.ifBlank { "Событие" }, muted = !inMonth)
}
if (events.size > 3) {
Text(
"+${events.size - 3}",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.Medium,
)
}
}
}
@Composable
private fun CalendarDayEventPill(text: String) {
private fun CalendarDayEventPill(text: String, muted: Boolean = false) {
// Живая тема: пилюля события — фон #ECF9DE, рамка #70B62B, r4, текст 10/12;
// вне текущего месяца — серые с белым текстом.
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.PrimaryLight)
.padding(horizontal = 4.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.PrimaryDark,
.background(if (muted) F7Colors.TextSecondary else F7Colors.PrimaryLight)
.border(1.dp, if (muted) F7Colors.TextSecondary else F7Colors.Primary, RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
fontSize = 10.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Medium,
color = if (muted) Color.White else F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -811,12 +829,14 @@ fun CalendarEventRow(
@Composable
private fun CalendarEventChip(event: CalendarEventItem, onClick: () -> Unit) {
// Живая тема: пилюля события — #ECF9DE с рамкой #70B62B, r4, текст 12/16
Text(
event.summary,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.background(F7Colors.Primary.copy(alpha = 0.15f))
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.PrimaryLight)
.border(1.dp, F7Colors.Primary, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
@@ -1075,7 +1095,13 @@ fun CalendarEventDetailSheet(
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(event.summary, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
// Живая тема: заголовки модалок 18/600
Text(
event.summary,
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
CalendarDetailRow(
iconUrl = "$base/themes/forbion/images/calendar/event-clock.svg",
label = "Время",
@@ -281,17 +281,19 @@ fun ContactListRow(
}
}
Column(modifier = Modifier.weight(1f)) {
// Живая тема: имя 16/500, детали 14/500 серым
Text(
contact.displayName.ifBlank { contact.email },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (contact.email.isNotBlank() && contact.displayName.isNotBlank()) {
Text(
contact.email,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -299,7 +301,7 @@ fun ContactListRow(
} else if (contact.phone.isNotBlank()) {
Text(
contact.phone,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_3969_9720)">
<g clip-path="url(#clip1_3969_9720)">
<rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25" stroke="#808080" stroke-width="1.5"/>
</g>
</g>
<defs>
<clipPath id="clip0_3969_9720">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_3969_9720">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 592 B

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

After

Width:  |  Height:  |  Size: 768 B

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

After

Width:  |  Height:  |  Size: 983 B

@@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_10995_59807)">
<circle cx="8.5" cy="13.5" r="1.5" transform="rotate(-90 8.5 13.5)" fill="#808080"/>
<circle cx="8.5" cy="8.5" r="1.5" transform="rotate(-90 8.5 8.5)" fill="#808080"/>
<circle cx="8.5" cy="3.5" r="1.5" transform="rotate(-90 8.5 3.5)" fill="#808080"/>
</g>
<defs>
<clipPath id="clip0_10995_59807">
<rect width="16" height="16" fill="white" transform="matrix(0 -1 1 0 0 16)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 538 B

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

After

Width:  |  Height:  |  Size: 1.6 KiB

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

After

Width:  |  Height:  |  Size: 779 B

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

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 1.4 KiB

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

After

Width:  |  Height:  |  Size: 749 B

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 415 B

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

After

Width:  |  Height:  |  Size: 385 B

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

After

Width:  |  Height:  |  Size: 1.4 KiB

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

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,13 +1,16 @@
package ru.forbion.f7cloud.feature.files
object FileIcons {
/** Локальные SVG хрома Файлов из боевой темы forbion (assets/chrome/, coil-svg). */
fun chromeAsset(name: String): String = "file:///android_asset/chrome/$name"
/**
* Иконка типа файла. Типы файлов ЛОКАЛЬНЫЕ SVG из дизайна (assets/filetypes/, грузятся
* coil-svg): офлайн + точные иконки макета (раньше тянулись с сервера аудит: офлайн ломался).
* Папка пока с сервера (иконки папки в наборе дизайна нет; TODO забрать из Figma).
* Папка фирменная folder-icon-colored.svg из боевой темы (тоже локально).
*/
fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String {
if (isDirectory) return "${serverUrl.trimEnd('/')}/core/img/filetypes/folder.svg"
if (isDirectory) return chromeAsset("folder-icon-colored.svg")
return "file:///android_asset/filetypes/${localIcon(name)}.svg"
}
@@ -4,34 +4,32 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -40,30 +38,59 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Radius
import ru.forbion.f7cloud.core.designsystem.F7Spacing
// Размеры — из боевой темы forbion (_mobile-files.css), копия один в один.
enum class FilesViewMode { List, Grid }
/** Квадратная кнопка-переключатель вида список⇄сетка справа от поиска (макет F-17/F-22). */
/**
* Чекбокс из боевой темы: 16×16, серый контур / зелёная заливка с галкой
* (checkbox-blank-outline-icon.svg / checkbox-marked-icon.svg). Тап-зона задаётся снаружи.
*/
@Composable
fun F7ThemeCheckbox(checked: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) {
AsyncImage(
model = FileIcons.chromeAsset(
if (checked) "checkbox-marked-icon.svg" else "checkbox-blank-outline-icon.svg",
),
contentDescription = if (checked) "Снять выбор" else "Выбрать",
modifier = modifier
.size(16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onToggle,
),
)
}
/** Стиль «пилюли» темы: фон #FBFBFB, рамка #E6E6E6, мягкая тень 0 1px 2px. */
private fun Modifier.f7PillSurface(shape: RoundedCornerShape): Modifier = this
.shadow(1.dp, shape, ambientColor = F7Colors.Border, spotColor = F7Colors.Border)
.clip(shape)
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, shape)
/** Круглая кнопка-переключатель список⇄сетка справа от поиска (40×40, иконка 16). */
@Composable
fun FilesViewToggleButton(viewMode: FilesViewMode, onToggle: () -> Unit) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(F7Radius.md))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.md))
.f7PillSurface(RoundedCornerShape(F7Radius.pill))
.clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (viewMode == FilesViewMode.List) Icons.Filled.GridView else Icons.AutoMirrored.Filled.List,
AsyncImage(
model = FileIcons.chromeAsset(
if (viewMode == FilesViewMode.List) "grid-files-gray.svg" else "list-view-icon.svg",
),
contentDescription = "Сменить вид",
tint = F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(16.dp),
)
}
}
/** Хлебные крошки: домой + сегменты пути через ›, прокрутка по горизонтали (макет F-21/F-22). */
/** Хлебные крошки: дом 16 + сегменты (все — серые 14/20) через шеврон 16, h-прокрутка. */
@Composable
fun FilesBreadcrumb(
segments: List<String>,
@@ -75,23 +102,28 @@ fun FilesBreadcrumb(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.sm),
.padding(horizontal = F7Spacing.sm, vertical = F7Spacing.xs),
horizontalArrangement = Arrangement.spacedBy(F7Spacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Home,
AsyncImage(
model = FileIcons.chromeAsset("home-bottom.svg"),
contentDescription = "В начало",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(22.dp)
.size(16.dp)
.clickable(onClick = onHomeClick),
)
segments.forEachIndexed { i, seg ->
Text(" ", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = F7Colors.TextSecondary,
modifier = Modifier.size(16.dp),
)
Text(
text = seg,
style = MaterialTheme.typography.bodyMedium,
color = if (i == segments.lastIndex) F7Colors.TextPrimary else F7Colors.TextSecondary,
color = F7Colors.TextSecondary,
maxLines = 1,
modifier = Modifier.clickable { onSegmentClick(i) },
)
@@ -99,7 +131,7 @@ fun FilesBreadcrumb(
}
}
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (макет F-18/F-22). */
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (пилюли 25dp, minWidth 86). */
@Composable
fun FilesFilterChips(
onTypeClick: () -> Unit,
@@ -111,7 +143,7 @@ fun FilesFilterChips(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.xs),
.padding(horizontal = F7Spacing.xs, vertical = F7Spacing.xs),
horizontalArrangement = Arrangement.spacedBy(F7Spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -125,16 +157,21 @@ fun FilesFilterChips(
private fun FilterChip(label: String, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(F7Radius.pill))
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.pill))
.height(25.dp)
.widthIn(min = 86.dp)
.f7PillSurface(RoundedCornerShape(F7Radius.pill))
.clickable(onClick = onClick)
.padding(horizontal = F7Spacing.md, vertical = F7Spacing.xs),
.padding(horizontal = F7Spacing.sm),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
/** Карточка сетки: чекбокс + звезда сверху, крупная иконка по центру, имя + ⋮ снизу (макет F-17). */
/**
* Карточка сетки (2 колонки): чекбокс 16 слева-сверху, зелёная звезда 16 справа-сверху
* (только у избранных), иконка 80 по центру, имя 14/20 + 16 снизу. Радиус 12, отступ 12.
*/
@Composable
fun FilesGridCard(
serverUrl: String,
@@ -146,41 +183,40 @@ fun FilesGridCard(
onMenuClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(12.dp)
Column(
modifier = modifier
.clip(RoundedCornerShape(F7Radius.lg))
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.lg))
.clip(shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Background)
.border(1.dp, F7Colors.Border, shape)
.clickable(onClick = onOpenClick)
.padding(F7Spacing.md),
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = selected,
onCheckedChange = { onSelectedChange() },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Spacer(Modifier.width(F7Spacing.sm))
F7ThemeCheckbox(checked = selected, onToggle = onSelectedChange)
Box(modifier = Modifier.weight(1f))
Icon(
imageVector = if (favorite) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = null,
tint = if (favorite) F7Colors.Primary else F7Colors.TextMuted,
modifier = Modifier.size(18.dp),
)
if (favorite) {
AsyncImage(
model = FileIcons.chromeAsset("star-green-full.svg"),
contentDescription = "В избранном",
modifier = Modifier.size(16.dp),
)
}
}
Spacer(Modifier.height(F7Spacing.sm))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = F7Spacing.lg),
.height(80.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null,
modifier = Modifier.size(48.dp),
modifier = Modifier.size(80.dp),
)
}
Spacer(Modifier.height(F7Spacing.sm))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = item.name,
@@ -191,12 +227,11 @@ fun FilesGridCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Filled.MoreVert,
AsyncImage(
model = FileIcons.chromeAsset("dots-vertic-gray.svg"),
contentDescription = "Меню",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(18.dp)
.size(16.dp)
.clickable(onClick = onMenuClick),
)
}
@@ -221,7 +256,7 @@ fun FilesEmptyState(
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(F7Radius.pill))
.clip(CircleShape)
.background(F7Colors.SurfaceMuted),
contentAlignment = Alignment.Center,
) {
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -26,11 +28,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.CheckboxDefaults
@@ -66,6 +64,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -103,6 +102,7 @@ fun FilesNavigationSidebar(
onToggleTreePath: (String) -> Unit,
onToggleSection: (String) -> Unit,
onWebOnlyClick: (String) -> Unit,
onCreateClick: (() -> Unit)? = null,
) {
val base = serverUrl.trimEnd('/')
AnimatedVisibility(
@@ -124,11 +124,18 @@ fun FilesNavigationSidebar(
Column(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(0.86f)
.background(F7Colors.Surface)
.fillMaxWidth(0.9f)
.background(F7Colors.Background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 12.dp),
.padding(start = 10.dp, top = 10.dp, end = 16.dp, bottom = 26.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
onCreateClick?.let { create ->
FilesSidebarCreateButton(
onClick = create,
modifier = Modifier.padding(bottom = 4.dp),
)
}
FilesSidebarRow(
serverUrl = base,
iconPath = "files/folder-gray.svg",
@@ -231,11 +238,13 @@ fun FilesNavigationSidebar(
}
if (usage.isNotBlank()) {
Spacer(Modifier.height(8.dp))
// Живая тема: квота по центру, серым 16/20, без прогресс-бара.
Text(
text = "Использовано $usage",
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyLarge,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(horizontal = 8.dp),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -305,6 +314,56 @@ private fun FilesTreeNodeRows(
}
}
/**
* Градиентная кнопка «Создать или загрузить» сверху панели (живая тема: 40dp, r100,
* градиент 104°, белая папка слева, разделитель и плюс справа).
*/
@Composable
fun FilesSidebarCreateButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(F7Radius.pill))
.background(
Brush.linearGradient(listOf(Color(0xFFC0FF7B), Color(0xFF7CBC3D))),
)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = FileIcons.chromeAsset("folder-white.svg"),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(
text = "Создать или загрузить",
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = Color(0xFFFDFDFD),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Box(
modifier = Modifier
.width(2.dp)
.height(40.dp)
.background(Color.White),
)
AsyncImage(
model = FileIcons.chromeAsset("plus-white.svg"),
contentDescription = "Создать",
modifier = Modifier
.padding(start = 14.dp)
.size(16.dp),
)
}
}
@Composable
private fun FilesSidebarRow(
serverUrl: String,
@@ -313,36 +372,34 @@ private fun FilesSidebarRow(
selected: Boolean = false,
onClick: () -> Unit,
) {
// Живая тема: строка minHeight 40, r8, иконка 20 серым, текст 16/20 #151515,
// активная — фон #ECF9DE; правых «⋮» в навигации нет.
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (selected) Color(0xFFE8F5E0) else Color.Transparent)
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 10.dp),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = "$serverUrl/themes/forbion/images/$iconPath",
contentDescription = null,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(8.dp))
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AsyncImage(
model = "$serverUrl/themes/forbion/images/files/dots-vertic-gray.svg",
contentDescription = null,
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
)
}
}
@@ -364,16 +421,12 @@ private fun FilesSidebarChildRow(
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
AsyncImage(
model = "$serverUrl/themes/forbion/images/files/dots-vertic-gray.svg",
contentDescription = null,
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
)
}
}
@@ -390,22 +443,23 @@ private fun FilesSidebarExpandableSection(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
.padding(horizontal = 8.dp, vertical = 10.dp),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = "$serverUrl/themes/forbion/images/$iconPath",
contentDescription = null,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(8.dp))
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(text = if (expanded) "" else "", color = F7Colors.TextSecondary)
@@ -559,7 +613,6 @@ fun FilesSearchBar(
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val base = serverUrl.trimEnd('/')
Box(
modifier = modifier
.height(40.dp)
@@ -569,7 +622,7 @@ fun FilesSearchBar(
spotColor = Color(0xFFCBCBCB),
)
.clip(RoundedCornerShape(100.dp))
.background(Color(0xFFFDFDFD))
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp)),
contentAlignment = Alignment.CenterStart,
) {
@@ -581,7 +634,7 @@ fun FilesSearchBar(
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = "$base/themes/forbion/images/header/search-gray.svg",
model = FileIcons.chromeAsset("search-gray.svg"),
contentDescription = null,
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
@@ -620,6 +673,16 @@ fun FilesSearchBar(
}
},
)
if (query.isNotEmpty()) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Очистить поиск",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(20.dp)
.clickable { onQueryChange("") },
)
}
}
}
}
@@ -733,47 +796,60 @@ private fun FilesHeaderColumn(
}
}
/**
* Панель выделения по живой теме: 40dp, чекбокс 16 «выбрать все», «Выбрать все» чёрным +
* «(Выбран N)» серым, справа пилюля «Действия» 25dp (без иконки).
*/
@Composable
fun FilesBulkActionsBar(
serverUrl: String,
selectedCount: Int,
allSelected: Boolean = false,
onToggleSelectAll: (() -> Unit)? = null,
onActionsClick: (Rect) -> Unit,
) {
var anchorBounds by remember { mutableStateOf<Rect?>(null) }
val base = serverUrl.trimEnd('/')
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
.height(40.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
F7ThemeCheckbox(checked = allSelected, onToggle = { onToggleSelectAll?.invoke() })
Text(
text = "Выбрано $selectedCount",
text = buildAnnotatedString {
append("Выбрать все ")
withStyle(SpanStyle(color = F7Colors.TextSecondary)) {
append("(Выбран $selectedCount)")
}
},
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
Box(
modifier = Modifier
.height(25.dp)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
.clickable {
anchorBounds?.let(onActionsClick)
}
.clickable { anchorBounds?.let(onActionsClick) }
.onGloballyPositioned { coords ->
anchorBounds = coords.boundsInWindow()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/files/dots-vertic-gray.svg",
contentDescription = null,
modifier = Modifier.size(14.dp),
contentScale = ContentScale.Fit,
Text(
"Действия",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
Text("Действия", style = MaterialTheme.typography.bodyMedium)
}
}
}
@@ -793,27 +869,36 @@ fun FilesListRow(
val hasExt = !item.isDirectory && item.name.contains('.')
val baseName = if (hasExt) item.name.substringBeforeLast('.') else item.name
val extension = if (hasExt && showExtensions) "." + item.name.substringAfterLast('.') else ""
// Строка 44dp по боевой теме (_mobile-files.css): чекбокс 16 в колонке 44, иконка 30
// (зелёная звезда 16 поверх у избранных), имя, размер 56 по центру, дата 88 вправо, ⋮ в 32.
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(F7Radius.md))
// выбранная строка — светло-зелёный фон (макет F-21)
.height(44.dp)
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable(onClick = onOpenClick)
.padding(horizontal = F7Spacing.sm, vertical = 10.dp),
.clickable(onClick = onOpenClick),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = selected,
onCheckedChange = { onSelectedChange() },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null,
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit,
)
Box(modifier = Modifier.size(44.dp), contentAlignment = Alignment.Center) {
F7ThemeCheckbox(checked = selected, onToggle = onSelectedChange)
}
Box(modifier = Modifier.size(30.dp)) {
AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null,
modifier = Modifier.size(30.dp),
contentScale = ContentScale.Fit,
)
if (item.favorite) {
AsyncImage(
model = FileIcons.chromeAsset("star-green-full.svg"),
contentDescription = "В избранном",
modifier = Modifier
.size(16.dp)
.offset(x = 20.dp, y = (-4).dp),
)
}
}
Text(
text = buildAnnotatedString {
append(baseName)
@@ -823,17 +908,38 @@ fun FilesListRow(
},
modifier = Modifier
.weight(1f)
.padding(horizontal = F7Spacing.sm),
.padding(start = F7Spacing.sm, end = F7Spacing.xs),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
item.size?.takeIf { !item.isDirectory }?.let { size ->
Text(
text = formatFileSize(size),
modifier = Modifier.width(56.dp),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
textAlign = TextAlign.Center,
)
}
item.lastModified?.let { mtime ->
Text(
text = formatRelativeTime(mtime),
modifier = Modifier.width(88.dp),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.width(32.dp)
.height(44.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -845,7 +951,7 @@ fun FilesListRow(
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "${serverUrl.trimEnd('/')}/themes/forbion/images/files/dots-vertic-gray.svg",
model = FileIcons.chromeAsset("dots-vertic-gray.svg"),
contentDescription = "Действия",
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
@@ -854,6 +960,15 @@ fun FilesListRow(
}
}
/** Относительное время как в вебе (moment.js): «5 мин. назад», «вчера», иначе дата. */
internal fun formatRelativeTime(epochMillis: Long): String =
android.text.format.DateUtils.getRelativeTimeSpanString(
epochMillis,
System.currentTimeMillis(),
android.text.format.DateUtils.MINUTE_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
@Composable
fun FileActionMenuPopup(
expanded: Boolean,
@@ -879,12 +994,14 @@ fun FileActionMenuPopup(
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier.widthIn(min = 260.dp, max = 320.dp),
shape = RoundedCornerShape(12.dp),
shadowElevation = 8.dp,
modifier = Modifier
.widthIn(min = 280.dp, max = 340.dp)
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)),
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp,
color = Color.White,
) {
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Column(modifier = Modifier.padding(4.dp)) {
entries.forEachIndexed { index, entry ->
if (entry.destructive && index > 0) {
HorizontalDivider(
@@ -895,18 +1012,20 @@ fun FileActionMenuPopup(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 25.dp)
.clip(RoundedCornerShape(6.dp))
.clickable {
onDismiss()
onAction(entry.action)
}
.padding(horizontal = 14.dp, vertical = 11.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = "$base/themes/forbion/images/${entry.iconPath}",
contentDescription = null,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
)
Text(
@@ -956,12 +1075,14 @@ fun FilesBulkActionMenuPopup(
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier.widthIn(min = 240.dp, max = 300.dp),
shape = RoundedCornerShape(12.dp),
shadowElevation = 8.dp,
modifier = Modifier
.widthIn(min = 240.dp, max = 300.dp)
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)),
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp,
color = Color.White,
) {
Column(modifier = Modifier.padding(vertical = 6.dp)) {
Column(modifier = Modifier.padding(4.dp)) {
entries.forEachIndexed { index, (action, label, icon) ->
if (index == entries.lastIndex) {
HorizontalDivider(
@@ -1013,25 +1134,26 @@ fun FilesDetailsSheet(
) {
if (item == null) return
var commentDraft by remember(item.relativePath) { mutableStateOf("") }
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
// Живая тема: панель на весь экран, фон #FBFBFB, без скруглений.
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.92f),
shape = RoundedCornerShape(16.dp),
color = F7Colors.Surface,
modifier = Modifier.fillMaxSize(),
color = F7Colors.Background,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
// Крестик закрытия — правый верхний угол (по макету)
// Крестик 40dp — правый верхний угол
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center,
) {
@@ -1043,75 +1165,81 @@ fun FilesDetailsSheet(
)
}
}
// Заголовок 18/600 + звезда 20 справа (живая тема)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
item.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
AsyncImage(
model = FileIcons.chromeAsset(
if (item.favorite) "star-green-full.svg" else "star-gray.svg",
),
contentDescription = "Избранное",
modifier = Modifier
.size(20.dp)
.clickable(onClick = onToggleFavorite),
)
}
Spacer(Modifier.height(8.dp))
// Чип владельца (пилюля 25dp) слева, «дата / размер» серым справа
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.height(25.dp)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight),
contentAlignment = Alignment.Center,
) {
Text(
ownerLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.SemiBold,
)
}
Text(
item.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
ownerLabel,
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
// Чип владельца (аватар-инициал + логин) — по макету
Row(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.SurfaceMuted)
.padding(start = 4.dp, end = 10.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight),
contentAlignment = Alignment.Center,
) {
Text(
ownerLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.SemiBold,
)
}
Text(
ownerLabel,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(horizontalAlignment = Alignment.End) {
val dateSize = buildList {
item.lastModified?.let {
add(SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)))
}
item.size?.let { add(formatFileSize(it)) }
Spacer(Modifier.weight(1f))
val dateSize = buildList {
item.lastModified?.let {
add(SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)))
}
if (dateSize.isNotEmpty()) {
Text(
dateSize.joinToString(" / "),
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.height(8.dp))
}
Icon(
if (item.favorite) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = "Избранное",
tint = if (item.favorite) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.clickable(onClick = onToggleFavorite),
item.size?.let { add(formatFileSize(it)) }
}
if (dateSize.isNotEmpty()) {
Text(
dateSize.joinToString(" / "),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
}
@@ -1121,14 +1249,14 @@ fun FilesDetailsSheet(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
FilesDetailsTabPill(
icon = Icons.Filled.ChatBubbleOutline,
iconAsset = "comment-icon-black.svg",
contentDescription = "Обсуждение",
selected = tab == FileDetailsTab.Events,
onClick = { onTabChange(FileDetailsTab.Events) },
modifier = Modifier.weight(1f),
)
FilesDetailsTabPill(
icon = Icons.Filled.Group,
iconAsset = "people-icon-black.svg",
contentDescription = "Общий доступ",
selected = tab == FileDetailsTab.Sharing,
onClick = { onTabChange(FileDetailsTab.Sharing) },
@@ -1179,27 +1307,31 @@ fun FilesDetailsSheet(
@Composable
private fun FilesDetailsTabPill(
icon: androidx.compose.ui.graphics.vector.ImageVector,
iconAsset: String,
contentDescription: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(14.dp)
// Живая тема: пилюля 30dp r100; активная — фон #ECF9DE, рамка #70B62B 20%; иконка темы 16.
val shape = RoundedCornerShape(100.dp)
Box(
modifier = modifier
.height(40.dp)
.height(30.dp)
.clip(shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface)
.border(1.dp, if (selected) Color.Transparent else F7Colors.Border, shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Background)
.border(
1.dp,
if (selected) F7Colors.Primary.copy(alpha = 0.2f) else F7Colors.Border,
shape,
)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
AsyncImage(
model = FileIcons.chromeAsset(iconAsset),
contentDescription = contentDescription,
tint = if (selected) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(16.dp),
)
}
}
@@ -191,9 +191,8 @@ fun FilesScreen(
FilesViewToggleButton(viewMode) {
viewMode = if (viewMode == FilesViewMode.List) FilesViewMode.Grid else FilesViewMode.List
}
// Создание (загрузка/новая папка) — раньше вызывалось из нижней панели,
// теперь кнопка в шапке (нижняя панель стала навигационной)
F7CreateButton(onClick = { showCreateMenu = true }, size = 40.dp)
// Кнопки создания в шапке нет (живая тема): «Создать или загрузить» —
// градиентная кнопка сверху панели навигации.
}
// Хлебные крошки
val crumbSegments = state.currentPath.split("/").filter { it.isNotBlank() }
@@ -203,9 +202,15 @@ fun FilesScreen(
onSegmentClick = { i -> vm.navigateToPath(session, crumbSegments.take(i + 1).joinToString("/")) },
)
if (selectionMode) {
val allSelected = displayItems.isNotEmpty() &&
state.selectedPaths.size >= displayItems.size
FilesBulkActionsBar(
serverUrl = session.serverUrl,
selectedCount = state.selectedPaths.size,
allSelected = allSelected,
onToggleSelectAll = {
if (allSelected) vm.clearSelection() else vm.selectAllVisible()
},
onActionsClick = { rect ->
bulkMenuAnchor = rect
bulkMenuOpen = true
@@ -323,6 +328,10 @@ fun FilesScreen(
vm.showWebOnlyMessage(title)
onSidebarOpenChange(false)
},
onCreateClick = {
onSidebarOpenChange(false)
showCreateMenu = true
},
)
FilesCreateMenu(
+4
View File
@@ -29,8 +29,12 @@ dependencies {
implementation project(':core:designsystem')
implementation project(':feature:files')
implementation project(':feature:contacts')
// создание задач/событий из письма (живой веб-паритет)
implementation project(':feature:tasks')
implementation project(':feature:calendar')
implementation libs.coroutines.android
implementation libs.json
testImplementation libs.junit
def composeBom = platform(libs.compose.bom)
implementation composeBom
implementation libs.compose.ui
@@ -0,0 +1,4 @@
<svg width="35" height="32" viewBox="0 0 35 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.33398 18.9808V25.9711C8.33398 25.9761 8.33209 25.981 8.32873 25.9845C8.32537 25.9881 8.3208 25.9901 8.31605 25.9901H2.27416C2.2718 25.9901 2.26947 25.9896 2.2673 25.9887C2.26512 25.9877 2.26314 25.9863 2.26148 25.9845C2.25812 25.981 2.25623 25.9761 2.25623 25.9711V5.02973C2.25623 5.02724 2.25669 5.02477 2.25759 5.02246C2.25849 5.02016 2.25981 5.01807 2.26148 5.0163C2.26484 5.01274 2.2694 5.01074 2.27416 5.01074H19.7382C19.7415 5.01074 19.7447 5.01166 19.7475 5.01343C19.7503 5.0152 19.7526 5.01773 19.7541 5.02076C19.7556 5.0238 19.7564 5.02722 19.7562 5.03065C19.756 5.03409 19.755 5.03742 19.7532 5.04028L16.2595 10.7647C16.2578 10.7674 16.2555 10.7696 16.2528 10.771C16.2501 10.7725 16.2471 10.7732 16.244 10.7731H8.35192C8.34716 10.7731 8.3426 10.7751 8.33924 10.7787C8.33587 10.7823 8.33398 10.7871 8.33398 10.7921V13.1672C8.33398 13.1723 8.33587 13.1771 8.33924 13.1807C8.3426 13.1842 8.34716 13.1862 8.35192 13.1862H14.9303C14.9328 13.1862 14.9352 13.1867 14.9374 13.1878C14.9397 13.1888 14.9417 13.1903 14.9434 13.1922C14.9451 13.194 14.9464 13.1963 14.9472 13.1987C14.948 13.2011 14.9484 13.2037 14.9483 13.2063L14.6937 18.9439C14.6935 18.9487 14.6915 18.9533 14.6881 18.9566C14.6848 18.96 14.6804 18.9618 14.6758 18.9618H8.35192C8.34716 18.9618 8.3426 18.9638 8.33924 18.9674C8.33587 18.9709 8.33398 18.9758 8.33398 18.9808Z" fill="#151515"/>
<path d="M26.2675 10.8048C26.2877 10.7837 26.2836 10.7731 26.255 10.7731H17.7975C17.7762 10.7731 17.7714 10.7638 17.783 10.7452L21.3221 5.03395C21.3317 5.01848 21.3453 5.01074 21.3629 5.01074H33.5155C33.5188 5.01074 33.5219 5.01213 33.5243 5.0146C33.5266 5.01708 33.5279 5.02043 33.5279 5.02393V10.7494C33.5279 10.7673 33.5218 10.7826 33.5095 10.7953C31.0839 13.3392 28.8027 16.2518 27.1169 19.4365C26.3193 20.944 25.6677 22.6434 25.4834 24.3571C25.4296 24.8592 25.4445 25.4268 25.445 25.9632C25.445 25.9811 25.4367 25.9901 25.4201 25.9901H18.4337C18.4164 25.9901 18.4072 25.981 18.4063 25.9627C18.3571 24.9535 18.4315 23.9955 18.6294 23.0886C19.0753 21.0468 20.0567 19.0346 21.1572 17.2724C22.6248 14.9221 24.3809 12.7806 26.2675 10.8048Z" fill="#70B62B"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

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