Compare commits
66 Commits
483e4107d7
...
v0.5.126
| Author | SHA1 | Date | |
|---|---|---|---|
| 92e0e9690b | |||
| 2cf5977dd4 | |||
| ab864aa7d7 | |||
| 2df46667e0 | |||
| 9ec3114506 | |||
| 8204e605e2 | |||
| 84a1ea9f27 | |||
| 753b574e1a | |||
| 3da61da45d | |||
| 999b52eb80 | |||
| 4c7566bbb2 | |||
| 65805a15af | |||
| bc9a31a026 | |||
| bbfb6ae424 | |||
| 1bc568b059 | |||
| 6895f1bffd | |||
| e5bbf0e123 | |||
| 2758d07e47 | |||
| c6732546f1 | |||
| dd5bdbc177 | |||
| 47534ceb59 | |||
| 8c90e83cef | |||
| a674721353 | |||
| 8f7eaff611 | |||
| 83e071673c | |||
| d1d61d0cf8 | |||
| 286ab9c8bf | |||
| b03cb09a6c | |||
| fb57156ee9 | |||
| 4c403e5ec2 | |||
| 00daa980a8 | |||
| 2709dba03f | |||
| c53885b230 | |||
| a696d45f2e | |||
| 2ae03264ff | |||
| 47c274e441 | |||
| 34bb16c59d | |||
| 0fcc575e76 | |||
| fff360fe39 | |||
| d047dea210 | |||
| cfa9fe2ffd | |||
| e25dcbc403 | |||
| 342e1e497d | |||
| 6f82a3fc56 | |||
| fe330be510 | |||
| 58d56feb76 | |||
| 58eed74b03 | |||
| 8186031676 | |||
| 9e5a16a605 | |||
| b56c9a5998 | |||
| d50eaaed3d | |||
| 3824ad86f2 | |||
| 89e32d2875 | |||
| a30af98a8d | |||
| fda34a5b69 | |||
| 3d9f3819d9 | |||
| 04f67e071b | |||
| b83d3dc872 | |||
| 530663754a | |||
| 8cee4d61bf | |||
| 2220fe4a89 | |||
| a727e2dbd1 | |||
| 335468b80f | |||
| b15a97d30e | |||
| 79e6b4ec83 | |||
| 8c3d422c85 |
@@ -1,14 +1,22 @@
|
||||
name: CI
|
||||
|
||||
# Активируется, когда на инфре B зарегистрирован self-hosted act_runner с меткой `f7-android`
|
||||
# (JDK 21 + Android SDK 36 — как на билд-машине). Пока раннера нет — джобы ждут исполнителя.
|
||||
# Регистрация раннера — инфра-шаг ячейки B (см. docs/CI.md).
|
||||
|
||||
# ⏸️ ПАРКОВКА (2026-07-09): CI отложен. Раннер на инфре B развёрнут, но
|
||||
# host-исполнение act_runner 0.2.13 в нашем окружении сломано (криво резолвит
|
||||
# пути к шагам/JS-экшенам: MODULE_NOT_FOUND / "No such file", задвоение
|
||||
# hostexecutor/.cache/act). Рабочий путь — Docker-исполнение с кастомным образом
|
||||
# (JDK 21 + Android SDK 36) и volume для ~/.gradle. До этого — сборки/релизы вручную
|
||||
# на билд-машине (см. docs/CI.md).
|
||||
#
|
||||
# Триггер временно ручной (workflow_dispatch), чтобы push'и не создавали падающие
|
||||
# прогоны. Когда настроим Docker-раннер — вернуть блок on: push/pull_request ниже.
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# branches: [main]
|
||||
# tags: ['v*']
|
||||
# pull_request:
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
||||
@@ -17,7 +25,7 @@ env:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# lint + unit-тесты + debug-сборка на каждый push в main и на PR
|
||||
# detekt + unit-тесты + Android Lint + debug-сборка на каждый push в main и на PR
|
||||
runs-on: [self-hosted, f7-android]
|
||||
if: github.ref_type != 'tag'
|
||||
steps:
|
||||
|
||||
@@ -24,3 +24,4 @@
|
||||
*.log
|
||||
*.tmp
|
||||
.cxx/
|
||||
dist/
|
||||
|
||||
@@ -2,6 +2,46 @@
|
||||
|
||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||
|
||||
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
|
||||
|
||||
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
|
||||
|
||||
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.1 — контакты): инкрементальная синхронизация по CTag. `CardDavClient.collectionSignature()` — дешёвый PROPFIND depth:1 за CTag адресных книг (расширение CalendarServer, поддерживается Nextcloud; фолбэк на sync-token), без address-data. `ContactsRepository.syncContacts` перед полной закачкой всех vCard сверяет подпись коллекций: не менялось → отдаём кэш (Room), сеть не грузим. Ускоряет pull-to-refresh (было — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста (regex-парсер подписи, без Android XmlPull). Дальше по Этапу 2: кэш событий календаря + CTag (сейчас экран календаря тянет события живьём по диапазону — под него сначала нужен кэш) | контракты не менялись | низкий: только оптимизация, поведение то же при изменениях
|
||||
|
||||
- 2026-07-09 | v0.5.123 (131) | Первый тегированный релиз в Gitea (тег `v0.5.123` + Release с APK и архивом исходников GPL). Включает хвосты этапов 0/1: FLAG_SECURE на экранах входа/PIN (аудит п.3, коммит cfa9fe2) + активированный CI-раннер на инфре B (docs/CI). Функциональных изменений в приложении относительно v0.5.122 нет, кроме FLAG_SECURE | контракты не менялись | низкий
|
||||
|
||||
- 2026-07-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI
|
||||
|
||||
- 2026-07-08 | v0.5.121 (129) | Нижняя панель: убрана зелёная подложка под активной кнопкой (фон всегда нейтральный). Активный раздел больше не выделяется фоном (зелёных вариантов иконок разделов нет). Зелёные иконки чатов/бургера при открытии остаются | контракты не менялись | низкий: только фон кнопок
|
||||
|
||||
- 2026-07-08 | v0.5.120 (128) | Нижняя панель: иконки-разделы (Почта/Карточки/Конференции) крупнее — 46dp (у серверных SVG внутренний «воздух», глиф оставался мелким). Стрелка/бургер — прежние 34dp | контракты не менялись | низкий: только размер иконок
|
||||
|
||||
- 2026-07-08 | v0.5.119 (127) | Убран чекбокс «Доверять сертификату» с экрана входа (раньше только в debug, теперь совсем). trustAllCerts=false по умолчанию; trust-all на сети/WebView в release и так отключён | контракты не менялись | низкий: только UI входа
|
||||
|
||||
- 2026-07-08 | v0.5.118 (126) | Первая RELEASE-сборка на боевом ключе владельца (release-keystore.jks, alias f7cloud, SHA-256 5F:E5:F0:6C…). Раньше отдавались debug-сборки (debug-ключ сервера) → Play Защита ругалась «незнакомый разработчик». Теперь release, подписан ключом владельца → предупреждения нет, обновления ставятся поверх. signing.env читает креды из /root/.f7cloud-keys/key.properties (в git НЕ коммитится). + иконки нижней панели крупнее (24dp→34dp, заполняют кружок) | контракты не менялись | средний: смена контура подписи на боевой ключ; debug-сборку v117 надо удалить перед установкой (иная подпись)
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Нижняя панель → навигационная по дизайну «Новое меню» (other/Новое меню.png): слева назад (в Конференциях — чаты), ярлыки-разделы Почта · Карточки · Конференции (активный подсвечен), справа выдвижное меню. Профиль/Уведомления/Настройки/Создать убраны из панели (первые три — в выдвижном меню, бейдж уведомлений на кнопке меню). `F7BottomBarConfig` упрощён, `F7MobileBottomBar` +слоты SectionMail/Cards/Conferences +activeTabKey. Создание Файлов/Контактов вызывалось ТОЛЬКО из панели → новый `F7CreateButton` (зелёный «+») в шапках Файлов и Контактов (Задачи/Поддержка/Почта свои имели) | контракты не менялись | средний: изменена навигация нижней панели; проверить переходы разделов и создание в Файлах/Контактах
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Дизайн выдвижного меню: сетка 3 колонки (было 4), ячейка кликабельна целиком, подпись во всю ширину. Состав пунктов точно по дизайну — Файлы/Календарь/Контакты/Карточки/Заметки + Bitrix/1C/Личный кабинет + Уведомления/Настройки (убраны Почта/Конференции/Задачи/Поддержка — они в нижней панели). Уведомления→шит, Настройки→настройки активного таба (нативные пункты рисуются локальной иконкой в круглом бейдже) | контракты не менялись | низкий: состав/вид меню
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Нативная панель профиля вместо веб-ЛК forbion: «Личный кабинет» открывал внешнюю ссылку (десктопный дропдаун forbion, криво на моб.) → теперь нативный bottom-card (ProfileSheet): аватар-инициал + имя (OCS cloud/user) + Открыть профиль/QR + Установить статус/Личные настройки/Учётные записи (deep-link веб) + О программе (нативный About) + Выйти. Точки входа: пункт меню + кнопка профиля | контракты не менялись; +GET ocs/cloud/user (имя) | низкий: только панель профиля
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Fix insets (edge-to-edge, targetSdk 36): контент залезал под системную навигацию снизу — добавлен нижний inset (`f7SafeBottomInsets`) к контенту в F7AppScaffold (верх уже был). | контракты не менялись | низкий: отступы контента
|
||||
|
||||
- 2026-07-08 | v0.5.117 (125) | Fix отображения писем: HTML-письма (широкие таблицы Битрикс/рассылок) уезжали за правый край — WebView без useWideViewPort игнорировал viewport width=device-width. Включены useWideViewPort+loadWithOverviewMode+пинч-зум | контракты не менялись | низкий: только рендер письма
|
||||
|
||||
- 2026-07-08 | v0.5.116 (124) | Меню-лончер (сетка приложений) → bottom-sheet на пол-экрана (требование владельца «выходить снизу вверх только на пол экрана»). `F7AppMenuSheet` был `fillMaxSize` с непрозрачным фоном → закрывал весь экран. Теперь: якорь снизу (`Alignment.BottomCenter`), высота `fillMaxHeight(0.6f)`, скруглённый верх (20dp) + тень, ручка-хендл, лёгкий скрим (0.18) над листом с тапом-для-закрытия, зазор `BottomBarReserve` под нижнюю панель, сетка иконок скроллится (`weight(1f)`). Экран под меню теперь виден сверху. Компилируется | контракты не менялись | низкий: только оверлей меню
|
||||
|
||||
- 2026-07-08 | v0.5.115 (123) | Бамп версии под дизайн-веху (0.5.114→0.5.115, code 122→123, стиль проекта: патч +1 / code +1). До этого Этапы 0/1 + весь дизайн ошибочно шли одной WIP-линией 0.5.114 (122) — теперь ревью-сборки дизайна помечены отдельной версией. Дальше — бампить на каждую заметную веху/ревью-сборку | версионирование | нет: только versionCode/Name
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Календаря (месячная сетка): по «Календарь · Месяц» — дни недели вынесены в серые пилюли; сетка стала безрамочной с тонкими гридлайнами (0.5dp Grey3) вместо скруглённых боксов; номер дня сверху-слева; сегодня — белое число в зелёном круге; событие — зелёная пилюля PrimaryLight (до 2 шт + «+N») вместо одного превью/точки. Компактная мобильная модель (месяц сверху + список событий выбранного дня снизу) сохранена — не десктопная таблица. `CalendarMonthGrid`/`CalendarDayCell`, убран неиспользуемый параметр daysWithEvents. Проверено сборкой | контракты не менялись | низкий: только вид месяца
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (правая панель свойств/доступа): `FilesDetailsSheet` по «Правая панель свойства/доступ» — табы из текст+подчёркивание → две иконки-пилюли (Обсуждение 💬 / Доступ 👥, активная — зелёная заливка PrimaryLight); шапка: крестик в правом верхнем углу, чип владельца (аватар-инициал + логин), дата/размер справа, звезда-избранное (wired на vm.toggleFavorite) вместо строки «Владелец…»; кнопка отправки комментария → зелёный круг-градиент с белой стрелкой. Иконки — Material (extended уже в модуле). Проверено сборкой | контракты не менялись | низкий: визуал панели, логика (шэры/события/избранное) та же
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Конференций (подложка чата): `TalkChatBackground` грузил chat-pattern.svg с сервера поверх плоского фона → заменён на бандл-ресурс `R.drawable.chat_background` (Подложка2 из дизайна — светлый градиент + водяной паттерн иконок, владелец выгрузил). PNG 481КБ → WebP 49КБ (q82, ×10), `feature/talk/res/drawable-nodpi`. ContentScale.Crop, full-bleed. Офлайн + точно из макета. Проверено сборкой | контракты не менялись | низкий: только фон экрана чата
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Почты (теги + сверка экрана сообщения): цвета тегов-пилюль сняты пипеткой с макета «Почта · Список сообщений» — Работа #DAEDD6/#28A413, Позже #F1EEE0/#C1B464, Личное #EFE4FB/#B574F7 (fallback tagChipColors, когда сервер не отдаёт colorHex; раньше приблизительные). Экран СООБЩЕНИЯ сверен с «Почта · Окно сообщения» — уже совпадает (тема-заголовок, sender-card, тело, плавающая пилюля «↩ Ответить» MailReplyFab на градиенте Primary), правок не требует. Проверено сборкой | контракты не менялись | низкий: только цвета тегов
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн-палитра из Figma «Брендбук» (владелец) в F7Colors: точные Base-токены (Black #151515, Grey1 #808080, Grey2 #F5F5F5, Grey3 #E6E6E6, Green #70B62B +30%/10%, Yellow #F6E120, Purple #9747FF, Red #FF7A66 +10%). Семантические токены (TextPrimary/Secondary/Border/SurfaceMuted/Primary) переведены на Base — основа сверена, совпала. **ИСПРАВЛЕНО расхождение: Error #D74642 → #FF7A66 (дизайн-красный)** — влияет на кнопки удаления/ошибки во ВСЁМ приложении. Yellow/Purple — под теги. Пропагируется на все экраны; шаг к закрытию аудитного долга «77 захардкоженных Color(0x…)» | дизайн-система | средний: Error-красный меняется app-wide (по дизайну); проверить экраны удаления/ошибок
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 3 — каркас списка): рескин главного экрана под мобильный макет (Figma files/*.png на инфре B). Новый FilesBrowserChrome.kt: поиск-пилюля + кнопка-переключатель список⇄сетка, хлебные крошки (🏠 › путь, навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) ВМЕСТО десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). Строки без разделителей (по макету). На токенах F7Spacing/F7Radius. material-icons-extended → feature:files (иконки chrome, потом точные из дизайна). Компилируется | контракты не менялись | средний: заметный UI-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве
|
||||
|
||||
@@ -39,8 +39,8 @@ android {
|
||||
applicationId 'ru.forbion.f7cloud.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 122
|
||||
versionName '0.5.114'
|
||||
versionCode 134
|
||||
versionName '0.5.126'
|
||||
missingDimensionStrategy 'default', 'f7'
|
||||
multiDexEnabled true
|
||||
|
||||
@@ -128,6 +128,7 @@ dependencies {
|
||||
implementation libs.firebase.messaging
|
||||
implementation libs.coroutines.play.services
|
||||
implementation libs.multidex
|
||||
implementation libs.core.splashscreen
|
||||
implementation libs.biometric
|
||||
implementation libs.fragment.ktx
|
||||
implementation libs.camera.camera2
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:theme="@style/Theme.F7.Splash"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
After Width: | Height: | Size: 57 KiB |
@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
||||
*/
|
||||
class CallIncomingActivity : ComponentActivity() {
|
||||
|
||||
private var callEndedListener: ((String) -> Unit)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
// Звонок сняли извне (30-с таймаут «не берут трубку», принят/отклонён из
|
||||
// уведомления) — закрываем полноэкранный входящий.
|
||||
launch.roomToken?.takeIf { it.isNotBlank() }?.let { token ->
|
||||
val listener: (String) -> Unit = { ended ->
|
||||
if (ended == token) {
|
||||
runOnUiThread {
|
||||
if (!isFinishing) finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
callEndedListener = listener
|
||||
F7IncomingCallQueue.addCallEndedListener(listener)
|
||||
}
|
||||
|
||||
setContent {
|
||||
F7Theme {
|
||||
IncomingCallScreen(
|
||||
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
|
||||
callEndedListener = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -43,6 +44,8 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Системный сплэш (фон + иконка) на холодном старте вместо белого экрана
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
if (!handleIncomingIntent(intent)) {
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.mobile.R
|
||||
|
||||
@@ -145,6 +146,7 @@ fun AppLockSetupDialog(
|
||||
onLockConfigured: () -> Unit = {},
|
||||
) {
|
||||
if (!visible) return
|
||||
F7SecureScreen() // создание/подтверждение PIN — не в скриншотах/recents
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context.findFragmentActivity()
|
||||
@@ -534,6 +536,7 @@ private fun AppLockUnlockScreen(
|
||||
lockSession: Int,
|
||||
onUnlocked: () -> Unit,
|
||||
) {
|
||||
F7SecureScreen() // экран разблокировки (PIN) — не в скриншотах/recents
|
||||
val context = LocalContext.current
|
||||
val activity = context.findFragmentActivity()
|
||||
var pin by remember { mutableStateOf("") }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
||||
|
||||
enum class AppTab(
|
||||
@@ -29,16 +32,14 @@ private data class AppMenuEntry(
|
||||
val webPath: String? = null,
|
||||
)
|
||||
|
||||
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
|
||||
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
|
||||
private val coreAppMenuEntries = listOf(
|
||||
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
|
||||
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
|
||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
|
||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
|
||||
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
|
||||
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
|
||||
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
|
||||
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
|
||||
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
|
||||
)
|
||||
|
||||
data class AppMenuExternalSite(
|
||||
@@ -71,7 +72,22 @@ fun appMenuItems(
|
||||
externalUrl = site.openUrl,
|
||||
)
|
||||
}
|
||||
return core + external
|
||||
// Нативные пункты по дизайну — действия обрабатываются в AppScaffold по label
|
||||
val native = listOf(
|
||||
F7AppMenuItem(
|
||||
label = "Уведомления",
|
||||
iconUrl = "",
|
||||
selected = false,
|
||||
localIcon = Icons.Filled.Notifications,
|
||||
),
|
||||
F7AppMenuItem(
|
||||
label = "Настройки",
|
||||
iconUrl = "",
|
||||
selected = false,
|
||||
localIcon = Icons.Filled.Settings,
|
||||
),
|
||||
)
|
||||
return core + external + native
|
||||
}
|
||||
|
||||
fun appTabFromMenuIndex(index: Int): AppTab? {
|
||||
|
||||
@@ -18,28 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -54,12 +38,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -69,13 +50,10 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import ru.forbion.f7cloud.mobile.BuildConfig
|
||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||
import ru.forbion.f7cloud.core.push.F7PushEvent
|
||||
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
||||
@@ -88,14 +66,10 @@ import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
|
||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
||||
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
|
||||
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
||||
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
|
||||
@@ -117,8 +91,8 @@ fun AppScaffold(
|
||||
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val authStore = remember { AuthStore(context) }
|
||||
var session by remember { mutableStateOf(authStore.load()) }
|
||||
val mainVm: MainViewModel = viewModel()
|
||||
val session by mainVm.session.collectAsState()
|
||||
var activeTab by rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { state -> state.value.name },
|
||||
@@ -173,17 +147,7 @@ fun AppScaffold(
|
||||
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
val forceLogout = {
|
||||
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
||||
session?.let { current ->
|
||||
logoutScope.launch { AppPasswordRevoker.revoke(current) }
|
||||
}
|
||||
OfficeWarmup.clear()
|
||||
OfficeWebViewPool.dispose()
|
||||
authStore.clear()
|
||||
session = null
|
||||
}
|
||||
val forceLogout = { mainVm.logout() }
|
||||
LaunchedEffect(session?.serverUrl, session?.username) {
|
||||
session?.let { OfficeWarmup.warm(it) }
|
||||
}
|
||||
@@ -196,10 +160,7 @@ fun AppScaffold(
|
||||
|
||||
F7Theme {
|
||||
if (session == null) {
|
||||
LoginScreen(onLogin = {
|
||||
authStore.save(it)
|
||||
session = it
|
||||
})
|
||||
LoginScreen(onLogin = { mainVm.login(it) })
|
||||
return@F7Theme
|
||||
}
|
||||
|
||||
@@ -483,6 +444,7 @@ fun AppScaffold(
|
||||
serverUrl = currentSession.serverUrl,
|
||||
userId = userId,
|
||||
config = bottomBarConfig,
|
||||
activeTabKey = activeTab.name,
|
||||
menuOpen = menuOpen,
|
||||
chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom,
|
||||
navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) ||
|
||||
@@ -499,36 +461,22 @@ fun AppScaffold(
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
onCreateClick = {
|
||||
when (activeTab) {
|
||||
AppTab.Files -> {
|
||||
if (F7AppPermissions.missing(context).isNotEmpty()) {
|
||||
onRequestRuntimePermissions { filesUploadRequest++ }
|
||||
} else {
|
||||
filesUploadRequest++
|
||||
}
|
||||
}
|
||||
AppTab.Contacts -> contactsCreateRequest++
|
||||
AppTab.Tasks -> tasksCreateRequest++
|
||||
AppTab.Support -> supportCreateRequest++
|
||||
else -> Unit
|
||||
onMailClick = {
|
||||
if (activeTab != AppTab.Mail) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = AppTab.Mail
|
||||
}
|
||||
},
|
||||
onProfileClick = {
|
||||
menuOpen = false
|
||||
profileOpen = true
|
||||
onCardsClick = {
|
||||
if (activeTab != AppTab.Deck) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = AppTab.Deck
|
||||
}
|
||||
},
|
||||
onNotificationsClick = {
|
||||
menuOpen = false
|
||||
hasNotificationBadge = false
|
||||
notificationsOpen = true
|
||||
},
|
||||
onSettingsClick = {
|
||||
when (activeTab) {
|
||||
AppTab.Mail -> mailSettingsOpen = true
|
||||
AppTab.Calendar -> calendarSettingsRequest++
|
||||
AppTab.Files -> filesSettingsOpen = true
|
||||
else -> Unit
|
||||
onConferencesClick = {
|
||||
if (activeTab != AppTab.Talk) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = AppTab.Talk
|
||||
}
|
||||
},
|
||||
onMenuClick = { menuOpen = !menuOpen },
|
||||
@@ -637,19 +585,43 @@ fun AppScaffold(
|
||||
onItemClick = { index ->
|
||||
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
|
||||
val external = item.externalUrl
|
||||
if (!external.isNullOrBlank()) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||
val label = item.label.trim()
|
||||
when {
|
||||
// Профиль — нативная панель вместо веб-ЛК forbion
|
||||
label.equals("Личный кабинет", ignoreCase = true) -> {
|
||||
menuOpen = false
|
||||
profileOpen = true
|
||||
}
|
||||
menuOpen = false
|
||||
} else {
|
||||
appTabFromMenuIndex(index)?.let { tab ->
|
||||
if (tab != activeTab) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = tab
|
||||
// Нативные пункты по дизайну меню
|
||||
label.equals("Уведомления", ignoreCase = true) -> {
|
||||
menuOpen = false
|
||||
hasNotificationBadge = false
|
||||
notificationsOpen = true
|
||||
}
|
||||
label.equals("Настройки", ignoreCase = true) -> {
|
||||
menuOpen = false
|
||||
when (activeTab) {
|
||||
AppTab.Mail -> mailSettingsOpen = true
|
||||
AppTab.Calendar -> calendarSettingsRequest++
|
||||
AppTab.Files -> filesSettingsOpen = true
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
menuOpen = false
|
||||
!external.isNullOrBlank() -> {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||
}
|
||||
menuOpen = false
|
||||
}
|
||||
else -> {
|
||||
appTabFromMenuIndex(index)?.let { tab ->
|
||||
if (tab != activeTab) {
|
||||
pushTabHistory(activeTab)
|
||||
activeTab = tab
|
||||
}
|
||||
}
|
||||
menuOpen = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -677,254 +649,3 @@ fun AppScaffold(
|
||||
}
|
||||
|
||||
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
|
||||
@Composable
|
||||
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val serverFocus = remember { FocusRequester() }
|
||||
val usernameFocus = remember { FocusRequester() }
|
||||
val passwordFocus = remember { FocusRequester() }
|
||||
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
||||
var loading by rememberSaveable { mutableStateOf(false) }
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val qrLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||
?: return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
||||
if (loginResult == null) {
|
||||
error = if (qrData.contains("/login/v2/flow/")) {
|
||||
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
||||
} else {
|
||||
"Не удалось распознать QR-код"
|
||||
}
|
||||
loading = false
|
||||
return@launch
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
||||
username = loginResult.username,
|
||||
appPassword = loginResult.appPassword,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchQrScan() {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitLogin() {
|
||||
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
return
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(serverUrl),
|
||||
username = username.trim(),
|
||||
appPassword = password,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val logoUrl = remember(serverUrl) {
|
||||
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.f7SafeTopInsets()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 440.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = "Forbion",
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(70.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = F7Colors.Background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Вход",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
Text(
|
||||
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
||||
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = "Адрес сервера",
|
||||
modifier = Modifier
|
||||
.focusRequester(serverFocus)
|
||||
.focusProperties { next = usernameFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { usernameFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { usernameFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = "Имя пользователя",
|
||||
modifier = Modifier
|
||||
.focusRequester(usernameFocus)
|
||||
.focusProperties { next = passwordFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { passwordFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "Пароль",
|
||||
modifier = Modifier.focusRequester(passwordFocus),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
),
|
||||
onEnter = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
)
|
||||
// Чекбокс trust-all показываем только в debug-сборках: в release он
|
||||
// всё равно игнорируется на уровне сети/WebView (см. UnsafeSsl).
|
||||
if (BuildConfig.DEBUG) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = trustAllCerts,
|
||||
onCheckedChange = { trustAllCerts = it },
|
||||
)
|
||||
Text(
|
||||
text = "Доверять сертификату (dev)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
F7PrimaryButton(
|
||||
text = if (loading) "…" else "Войти",
|
||||
onClick = { submitLogin() },
|
||||
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7TextButton(
|
||||
text = "Сканировать QR для входа",
|
||||
onClick = { launchQrScan() },
|
||||
enabled = !loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (loading) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (error != null) {
|
||||
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
F7TextButton(
|
||||
text = "Очистить",
|
||||
onClick = {
|
||||
error = null
|
||||
password = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -21,22 +42,30 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
|
||||
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
|
||||
import ru.forbion.f7cloud.core.network.F7Notification
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.NotificationsRepository
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||
|
||||
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
|
||||
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
|
||||
@@ -54,46 +83,178 @@ fun ProfileSheet(
|
||||
onLogout: () -> Unit,
|
||||
onScanBrowserQr: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val base = session.serverUrl.trimEnd('/')
|
||||
val userId = session.davUserId ?: session.username
|
||||
var displayName by remember(session.username) { mutableStateOf(userId) }
|
||||
var aboutOpen by remember { mutableStateOf(false) }
|
||||
|
||||
// Отображаемое имя тянем с сервера (OCS), пока — логин
|
||||
LaunchedEffect(visible, session.username) {
|
||||
if (visible) displayName = fetchDisplayName(session).ifBlank { userId }
|
||||
}
|
||||
|
||||
fun openWeb(path: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("$base$path")))
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
F7FloatingPanel(
|
||||
visible = visible,
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
||||
Text(
|
||||
text = session.username,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text(
|
||||
text = session.serverUrl,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
session.davUserId?.let { davId ->
|
||||
Text(
|
||||
text = "ID: $davId",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка QR
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.PrimaryLight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { openWeb("/u/$userId") }
|
||||
.padding(vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
"Открыть профиль",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(F7Colors.Grey2)
|
||||
.clickable {
|
||||
onDismiss()
|
||||
onScanBrowserQr()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.QrCodeScanner,
|
||||
contentDescription = "Сканировать QR браузера",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ProfileMenuRow(Icons.Filled.TaskAlt, "Установить статус") { openWeb("/settings/user") }
|
||||
ProfileMenuRow(Icons.Filled.Person, "Личные настройки") { openWeb("/settings/user") }
|
||||
ProfileMenuRow(Icons.Filled.Group, "Учётные записи") { openWeb("/settings/user/security") }
|
||||
ProfileMenuRow(Icons.Outlined.Info, "О программе и что нового") { aboutOpen = true }
|
||||
ProfileMenuRow(
|
||||
icon = Icons.AutoMirrored.Filled.Logout,
|
||||
label = "Выйти",
|
||||
tint = F7Colors.Error,
|
||||
) {
|
||||
onDismiss()
|
||||
onLogout()
|
||||
}
|
||||
F7PrimaryButton(
|
||||
text = "Сканировать QR браузера",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onScanBrowserQr()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7PrimaryButton(
|
||||
text = "Выйти",
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onLogout()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (aboutOpen) {
|
||||
F7AboutDialog(onDismiss = { aboutOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileMenuRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tint: Color = F7Colors.TextPrimary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, color = tint)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F7AboutDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val version = remember {
|
||||
runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
}.getOrNull() ?: "—"
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Закрыть") }
|
||||
},
|
||||
title = { Text("F7cloud Mobile") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Версия $version", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Нативный клиент F7cloud: почта, файлы, календарь, контакты, задачи, конференции, поддержка.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Отображаемое имя пользователя из OCS (fallback — пусто). */
|
||||
private suspend fun fetchDisplayName(session: AuthSession): String = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val client = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
|
||||
val request = Request.Builder().url(url).applyOcsJson().get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
JSONObject(body)
|
||||
.optJSONObject("ocs")
|
||||
?.optJSONObject("data")
|
||||
?.optString("displayname")
|
||||
.orEmpty()
|
||||
}
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||
import ru.forbion.f7cloud.mobile.BuildConfig
|
||||
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||
|
||||
@Composable
|
||||
internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
||||
F7SecureScreen() // ввод пароля — не в скриншотах/recents
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val serverFocus = remember { FocusRequester() }
|
||||
val usernameFocus = remember { FocusRequester() }
|
||||
val passwordFocus = remember { FocusRequester() }
|
||||
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
||||
var loading by rememberSaveable { mutableStateOf(false) }
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val qrLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||
?: return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
||||
if (loginResult == null) {
|
||||
error = if (qrData.contains("/login/v2/flow/")) {
|
||||
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
||||
} else {
|
||||
"Не удалось распознать QR-код"
|
||||
}
|
||||
loading = false
|
||||
return@launch
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
||||
username = loginResult.username,
|
||||
appPassword = loginResult.appPassword,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun launchQrScan() {
|
||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitLogin() {
|
||||
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
return
|
||||
}
|
||||
val newSession = AuthSession(
|
||||
serverUrl = normalizeServerUrl(serverUrl),
|
||||
username = username.trim(),
|
||||
appPassword = password,
|
||||
trustAllCerts = trustAllCerts,
|
||||
)
|
||||
scope.launch {
|
||||
loading = true
|
||||
error = null
|
||||
AuthVerifier.verify(newSession)
|
||||
.onSuccess { verified -> onLogin(verified) }
|
||||
.onFailure { error = it.message ?: "Ошибка входа" }
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Логотип — локальный (assets/login/big-forbion.svg из живой темы): виден офлайн и до
|
||||
// ввода адреса сервера.
|
||||
val logoUrl = "file:///android_asset/login/big-forbion.svg"
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Живая тема логина: фон страницы --backgroud-color-main-darkgray
|
||||
.background(Color(0xFFE5EFE8))
|
||||
.f7SafeTopInsets()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 440.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = "Forbion",
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(70.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = F7Colors.Background,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Вход",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
Text(
|
||||
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
||||
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = "Адрес сервера",
|
||||
modifier = Modifier
|
||||
.focusRequester(serverFocus)
|
||||
.focusProperties { next = usernameFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { usernameFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { usernameFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = "Имя пользователя",
|
||||
modifier = Modifier
|
||||
.focusRequester(usernameFocus)
|
||||
.focusProperties { next = passwordFocus },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocus.requestFocus() },
|
||||
),
|
||||
onEnter = { passwordFocus.requestFocus() },
|
||||
)
|
||||
F7OutlinedField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "Пароль",
|
||||
modifier = Modifier.focusRequester(passwordFocus),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
),
|
||||
onEnter = {
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
submitLogin()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
F7PrimaryButton(
|
||||
text = if (loading) "…" else "Войти",
|
||||
onClick = { submitLogin() },
|
||||
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// Живая тема: вторичная кнопка-ссылка 40dp, фон #F0F1F4, r8, текст 14 чёрный
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFF0F1F4))
|
||||
.clickable(enabled = !loading, onClick = { launchQrScan() }),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"Сканировать QR для входа",
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
if (loading) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (error != null) {
|
||||
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
F7TextButton(
|
||||
text = "Очистить",
|
||||
onClick = {
|
||||
error = null
|
||||
password = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.forbion.f7cloud.mobile.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||
|
||||
/**
|
||||
* Держит session/auth-жизненный цикл верхнего уровня (вынесено из AppScaffold).
|
||||
* В ViewModel сессия переживает пересоздание Activity (поворот и т.п.) — раньше
|
||||
* жила в remember и перечитывалась из AuthStore.
|
||||
*/
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val authStore = AuthStore(app)
|
||||
private val _session = MutableStateFlow(authStore.load())
|
||||
val session: StateFlow<AuthSession?> = _session.asStateFlow()
|
||||
|
||||
fun login(newSession: AuthSession) {
|
||||
authStore.save(newSession)
|
||||
_session.value = newSession
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
||||
_session.value?.let { current ->
|
||||
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
|
||||
}
|
||||
OfficeWarmup.clear()
|
||||
OfficeWebViewPool.dispose()
|
||||
authStore.clear()
|
||||
_session.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Логотип F7 для сплэша: вектор из темы forbion (logo-header.svg) вместо растянутого
|
||||
растра лаунчер-иконки (мыло). Глиф (35×32, контент x 2.26–33.53 / y 5.01–25.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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Сплэш при холодном старте (androidx core-splashscreen): фон в цвет
|
||||
F7Colors.Background + векторный логотип F7 (лаунчер-иконка — растянутый
|
||||
растр, мылится на сплэше); после — прежняя тема. -->
|
||||
<style name="Theme.F7.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#FBFBFB</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/f7_splash_logo</item>
|
||||
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -9,10 +9,12 @@ import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -23,8 +25,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -35,8 +39,10 @@ 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.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -51,11 +57,18 @@ private val BottomBarReserve: Dp = 90.dp
|
||||
private val MenuIconSize = 62.dp
|
||||
private val MenuGridGap = 20.dp
|
||||
|
||||
// Меню-лист выезжает снизу и занимает ~пол-экрана (не весь), по требованию владельца
|
||||
private const val MenuSheetHeightFraction = 0.6f
|
||||
private val MenuSheetShape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
|
||||
data class F7AppMenuItem(
|
||||
val label: String,
|
||||
val iconUrl: String,
|
||||
val selected: Boolean,
|
||||
val externalUrl: String? = null,
|
||||
// Локальная иконка вместо серверной (для нативных пунктов — Уведомления/Настройки),
|
||||
// рисуется в круглом бейдже с зелёной обводкой под стиль glass-иконок.
|
||||
val localIcon: ImageVector? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -78,41 +91,76 @@ fun F7AppMenuSheet(
|
||||
}
|
||||
val base = serverUrl.trimEnd('/')
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(250)) + slideInVertically(
|
||||
animationSpec = tween(350),
|
||||
initialOffsetY = { it },
|
||||
),
|
||||
exit = fadeOut(tween(200)) + slideOutVertically(
|
||||
animationSpec = tween(300),
|
||||
targetOffsetY = { it },
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = BottomBarReserve)
|
||||
.navigationBarsPadding()
|
||||
.background(F7Colors.Background)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
F7AppMenuSearchField(
|
||||
serverUrl = base,
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.18f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(250)) + slideInVertically(
|
||||
animationSpec = tween(350),
|
||||
initialOffsetY = { it },
|
||||
),
|
||||
exit = fadeOut(tween(200)) + slideOutVertically(
|
||||
animationSpec = tween(300),
|
||||
targetOffsetY = { it },
|
||||
),
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
.fillMaxHeight(MenuSheetHeightFraction)
|
||||
.padding(bottom = BottomBarReserve)
|
||||
.navigationBarsPadding()
|
||||
.shadow(16.dp, MenuSheetShape, clip = false)
|
||||
.clip(MenuSheetShape)
|
||||
.background(F7Colors.Background)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
// Ручка-хендл
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = 2.dp, bottom = 10.dp)
|
||||
.width(40.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(F7Colors.Border),
|
||||
)
|
||||
F7AppMenuSearchField(
|
||||
serverUrl = base,
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 20.dp),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = filteredItems,
|
||||
key = { index, item -> "${item.label}-$index" },
|
||||
@@ -132,6 +180,7 @@ fun F7AppMenuSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,17 +244,39 @@ private fun F7AppMenuGridItem(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(MenuIconSize)
|
||||
.clickable(onClick = onClick),
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
val localIcon = item.localIcon
|
||||
if (localIcon != null) {
|
||||
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(MenuIconSize)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(1.5.dp, F7Colors.Green30, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
localIcon,
|
||||
contentDescription = item.label,
|
||||
tint = F7Colors.Primary,
|
||||
modifier = Modifier.size(MenuIconSize * 0.46f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
|
||||
@@ -3,10 +3,9 @@ package ru.forbion.f7cloud.core.designsystem
|
||||
enum class F7BottomBarSlot {
|
||||
Chats,
|
||||
NavBack,
|
||||
Create,
|
||||
Profile,
|
||||
Notifications,
|
||||
Settings,
|
||||
SectionMail,
|
||||
SectionCards,
|
||||
SectionConferences,
|
||||
Menu,
|
||||
}
|
||||
|
||||
@@ -16,72 +15,22 @@ data class F7BottomBarConfig(
|
||||
val buttonCount: Int get() = slots.size
|
||||
|
||||
companion object {
|
||||
// Единая навигационная панель по дизайну «Новое меню»:
|
||||
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
|
||||
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
|
||||
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun forContext(
|
||||
tabKey: String,
|
||||
talkInRoom: Boolean,
|
||||
): F7BottomBarConfig = when (tabKey) {
|
||||
"Talk" -> if (talkInRoom) {
|
||||
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
|
||||
} else {
|
||||
F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Chats,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
"Files" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Settings,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Contacts" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Tasks" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Support" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Mail", "Calendar" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Settings,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
else -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
): F7BottomBarConfig = F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.SectionMail,
|
||||
F7BottomBarSlot.SectionCards,
|
||||
F7BottomBarSlot.SectionConferences,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,11 @@ object F7Colors {
|
||||
val SecondaryButtonBg = Color(0xFFFDFDFD)
|
||||
val SecondaryButtonBorder = Grey3
|
||||
|
||||
// Дизайн-красный #FF7A66 (был #D74642 — расходился с макетом)
|
||||
val Error = Red
|
||||
val ErrorBg = Red10
|
||||
// Семантика ошибок — по боевой теме forbion (_base.css: --color-element-error /
|
||||
// --color-text-error / --color-error); базовый Red #FF7A66 из брендбука остаётся для тегов.
|
||||
val Error = Color(0xFFD74642)
|
||||
val ErrorText = Color(0xFFD42722)
|
||||
val ErrorBg = Color(0xFFFFE2E2)
|
||||
|
||||
val StatusNew = Color(0xFF2B9AB6)
|
||||
val StatusProgress = Color(0xFF70B62B)
|
||||
|
||||
@@ -16,8 +16,11 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -43,11 +46,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
/**
|
||||
@@ -69,6 +75,46 @@ fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () ->
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый экран ошибки (по центру): иконка + сообщение + кнопка «Повторить».
|
||||
* Используется везде, где загрузка упала и данных нет — вместо россыпи красного текста.
|
||||
*/
|
||||
@Composable
|
||||
fun F7ErrorState(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.Error.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("!", color = F7Colors.Error, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
F7SecondaryButton(text = "Повторить", onClick = onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7ModuleScreen(
|
||||
title: String? = null,
|
||||
@@ -76,6 +122,9 @@ fun F7ModuleScreen(
|
||||
loading: Boolean = false,
|
||||
error: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
// Действие «Повторить» в экране ошибки; по умолчанию — как onRefresh.
|
||||
// Позволяет дать retry, не показывая хедер-кнопку ↻ (когда onRefresh не задан).
|
||||
onErrorRetry: (() -> Unit)? = onRefresh,
|
||||
headerActions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
@@ -126,14 +175,22 @@ fun F7ModuleScreen(
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
// Ошибка без данных → центрированный экран с «Повторить»
|
||||
F7ErrorState(
|
||||
message = error,
|
||||
onRetry = onErrorRetry,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +627,10 @@ fun F7AppScaffold(
|
||||
content(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.f7SafeTopInsets(),
|
||||
// Отступы под статус-бар (сверху) И системную навигацию (снизу),
|
||||
// иначе на edge-to-edge (targetSdk 36) контент залезает под панели.
|
||||
.f7SafeTopInsets()
|
||||
.f7SafeBottomInsets(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Зелёная круглая кнопка «+» — единый элемент создания в шапках экранов
|
||||
* (Почта/Файлы/Контакты/…). Градиент Primary, как у других зелёных кнопок.
|
||||
*/
|
||||
@Composable
|
||||
fun F7CreateButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 44.dp,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.shadow(3.dp, CircleShape, spotColor = F7Colors.Primary.copy(alpha = 0.25f))
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
|
||||
),
|
||||
)
|
||||
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "+",
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,21 +21,25 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
data class F7BottomBarActions(
|
||||
val onChatsClick: () -> Unit = {},
|
||||
val onNavBackClick: () -> Unit = {},
|
||||
val onCreateClick: () -> Unit = {},
|
||||
val onProfileClick: () -> Unit = {},
|
||||
val onNotificationsClick: () -> Unit = {},
|
||||
val onSettingsClick: () -> Unit = {},
|
||||
val onMailClick: () -> Unit = {},
|
||||
val onCardsClick: () -> Unit = {},
|
||||
val onConferencesClick: () -> Unit = {},
|
||||
val onMenuClick: () -> Unit = {},
|
||||
)
|
||||
|
||||
private val BottomBarButtonSize = 55.dp
|
||||
private val BottomBarIconSize = 24.dp
|
||||
private val BottomBarIconSize = 34.dp
|
||||
|
||||
// Иконки-разделы (Почта/Карточки/Конференции) — серверные SVG с внутренним
|
||||
// «воздухом», поэтому рендерим крупнее, чтобы заполняли кружок.
|
||||
private val BottomBarSectionIconSize = 46.dp
|
||||
private val BottomBarGap = 8.dp
|
||||
private val BottomBarOuterPaddingH = 6.dp
|
||||
private val BottomBarOuterPaddingV = 6.dp
|
||||
@@ -43,9 +47,6 @@ private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
||||
private val BottomBarBorderBrush = Brush.linearGradient(
|
||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||
)
|
||||
private val BottomBarHighlightBrush = Brush.linearGradient(
|
||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun F7MobileBottomBar(
|
||||
@@ -53,6 +54,7 @@ fun F7MobileBottomBar(
|
||||
userId: String,
|
||||
config: F7BottomBarConfig,
|
||||
actions: F7BottomBarActions,
|
||||
activeTabKey: String = "",
|
||||
menuOpen: Boolean = false,
|
||||
chatsHighlighted: Boolean = false,
|
||||
navBackHighlighted: Boolean = false,
|
||||
@@ -87,36 +89,31 @@ fun F7MobileBottomBar(
|
||||
"$base/themes/forbion/images/header/chat-icon-gray.svg"
|
||||
},
|
||||
contentDescription = "Чаты",
|
||||
highlighted = chatsHighlighted,
|
||||
onClick = actions.onChatsClick,
|
||||
)
|
||||
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
|
||||
contentDescription = "Папки",
|
||||
highlighted = navBackHighlighted,
|
||||
iconRotation = if (navBackHighlighted) 180f else 0f,
|
||||
onClick = actions.onNavBackClick,
|
||||
)
|
||||
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
|
||||
contentDescription = "Создать",
|
||||
onClick = actions.onCreateClick,
|
||||
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg",
|
||||
contentDescription = "Почта",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
onClick = actions.onMailClick,
|
||||
)
|
||||
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
|
||||
contentDescription = "Профиль",
|
||||
onClick = actions.onProfileClick,
|
||||
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg",
|
||||
contentDescription = "Карточки",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
onClick = actions.onCardsClick,
|
||||
)
|
||||
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
|
||||
contentDescription = "Уведомления",
|
||||
showBadge = showNotificationBadge,
|
||||
onClick = actions.onNotificationsClick,
|
||||
)
|
||||
F7BottomBarSlot.Settings -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/setting-menu-icon.svg",
|
||||
contentDescription = "Настройки",
|
||||
onClick = actions.onSettingsClick,
|
||||
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg",
|
||||
contentDescription = "Конференции",
|
||||
iconSize = BottomBarSectionIconSize,
|
||||
onClick = actions.onConferencesClick,
|
||||
)
|
||||
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
|
||||
iconUrl = if (menuOpen) {
|
||||
@@ -125,7 +122,7 @@ fun F7MobileBottomBar(
|
||||
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
||||
},
|
||||
contentDescription = "Меню",
|
||||
highlighted = menuOpen,
|
||||
showBadge = showNotificationBadge,
|
||||
onClick = actions.onMenuClick,
|
||||
)
|
||||
}
|
||||
@@ -138,22 +135,16 @@ private fun F7BottomBarIconSlot(
|
||||
iconUrl: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
highlighted: Boolean = false,
|
||||
iconRotation: Float = 0f,
|
||||
showBadge: Boolean = false,
|
||||
iconSize: Dp = BottomBarIconSize,
|
||||
) {
|
||||
val bg = if (highlighted) BottomBarHighlightBrush else null
|
||||
// Зелёной подложки под активной кнопкой быть не должно — фон всегда нейтральный.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(BottomBarButtonSize)
|
||||
.clip(BottomBarButtonShape)
|
||||
.then(
|
||||
if (bg != null) {
|
||||
Modifier.background(bg, BottomBarButtonShape)
|
||||
} else {
|
||||
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
|
||||
},
|
||||
)
|
||||
.background(Color(0x99FFFFFF), BottomBarButtonShape)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
brush = BottomBarBorderBrush,
|
||||
@@ -170,7 +161,7 @@ private fun F7BottomBarIconSlot(
|
||||
model = iconUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.size(BottomBarIconSize)
|
||||
.size(iconSize)
|
||||
.graphicsLayer { rotationZ = iconRotation },
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает
|
||||
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
|
||||
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
|
||||
*/
|
||||
@Composable
|
||||
fun F7SecureScreen() {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val window = (context as? Activity)?.window
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ data class DavCalendar(
|
||||
val href: String,
|
||||
val displayName: String,
|
||||
val color: String? = null,
|
||||
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||
val ctag: String = "",
|
||||
)
|
||||
|
||||
data class DavEvent(
|
||||
@@ -103,7 +106,7 @@ object CalDavClient {
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/></d:prop>
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||
@@ -129,7 +132,15 @@ object CalDavClient {
|
||||
calendar: DavCalendar,
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
): List<DavEvent> {
|
||||
): List<DavEvent> = parseEventsXml(queryEventsRawXml(client, calendar, rangeStart, rangeEnd), calendar)
|
||||
|
||||
/** Сырой REPORT-ответ (для кэширования на диске с ключом по CTag). */
|
||||
fun queryEventsRawXml(
|
||||
client: OkHttpClient,
|
||||
calendar: DavCalendar,
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
): String {
|
||||
val startStr = formatCalDavTime(rangeStart)
|
||||
val endStr = formatCalDavTime(rangeEnd)
|
||||
val body = """
|
||||
@@ -146,10 +157,13 @@ object CalDavClient {
|
||||
</c:calendar-query>
|
||||
""".trimIndent()
|
||||
val href = calendar.href.trimEnd('/') + "/"
|
||||
val xml = report(client, href, body)
|
||||
return parseCalendarQueryResponses(xml, calendar)
|
||||
return report(client, href, body)
|
||||
}
|
||||
|
||||
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
|
||||
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
|
||||
parseCalendarQueryResponses(xml, calendar)
|
||||
|
||||
fun createEvent(
|
||||
client: OkHttpClient,
|
||||
calendar: DavCalendar,
|
||||
@@ -782,6 +796,7 @@ object CalDavClient {
|
||||
var href = ""
|
||||
var displayName = ""
|
||||
var calendarColor: String? = null
|
||||
var ctag = ""
|
||||
var isCollection = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
@@ -791,11 +806,13 @@ object CalDavClient {
|
||||
href = ""
|
||||
displayName = ""
|
||||
calendarColor = null
|
||||
ctag = ""
|
||||
isCollection = false
|
||||
}
|
||||
"collection" -> if (inResponse) isCollection = true
|
||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
||||
"getctag" -> if (inResponse) ctag = parser.readText().trim()
|
||||
"href" -> if (inResponse) href = parser.readText().trim()
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
||||
@@ -809,7 +826,7 @@ object CalDavClient {
|
||||
val name = displayName.ifBlank {
|
||||
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
||||
}
|
||||
out += DavCalendar(href = full, displayName = name, color = calendarColor)
|
||||
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
|
||||
}
|
||||
}
|
||||
inResponse = false
|
||||
|
||||
@@ -69,6 +69,53 @@ object CardDavClient {
|
||||
return out.distinctBy { "${it.uid}|${it.email}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Дешёвая «подпись» состояния адресных книг — CTag коллекций (расширение
|
||||
* CalendarServer, поддерживается Nextcloud), с фолбэком на sync-token.
|
||||
* Один PROPFIND depth:1 без address-data. Если подпись не изменилась с прошлой
|
||||
* синхронизации — контакты качать не нужно. null при ошибке/неподдержке → полный sync.
|
||||
*/
|
||||
fun collectionSignature(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
userId: String,
|
||||
): String? = runCatching {
|
||||
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop><cs:getctag/><d:sync-token/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, base, depth = 1, body)
|
||||
parseCollectionSignature(xml).ifBlank { null }
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Парсит PROPFIND-ответ в стабильную подпись «href=ctag» (по книгам, отсортировано).
|
||||
* Regex-парсинг (как ICS в проекте) — чистая функция, тестируется без Android XmlPull.
|
||||
*/
|
||||
internal fun parseCollectionSignature(xml: String): String {
|
||||
val entries = sortedSetOf<String>()
|
||||
for (m in responseBlockPattern.findAll(xml)) {
|
||||
val block = m.value
|
||||
val href = hrefTagPattern.find(block)?.groupValues?.get(1)?.trim().orEmpty()
|
||||
val tag = (
|
||||
getctagPattern.find(block)?.groupValues?.get(1)
|
||||
?: syncTokenPattern.find(block)?.groupValues?.get(1)
|
||||
)?.trim().orEmpty()
|
||||
if (href.isNotBlank() && tag.isNotBlank()) {
|
||||
entries += "$href=$tag"
|
||||
}
|
||||
}
|
||||
return entries.joinToString("\n")
|
||||
}
|
||||
|
||||
private val responseBlockPattern = Regex("(?is)<(?:\\w+:)?response\\b.*?</(?:\\w+:)?response>")
|
||||
private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)</(?:\\w+:)?href>")
|
||||
private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)</(?:\\w+:)?getctag>")
|
||||
private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)</(?:\\w+:)?sync-token>")
|
||||
|
||||
fun createContact(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Тесты подписи коллекций CardDAV (CTag/sync-token) — основа инкрементальной
|
||||
* синхронизации: если подпись не изменилась, контакты не перекачиваются.
|
||||
*/
|
||||
class CardDavSignatureTest {
|
||||
|
||||
private val ns = """xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/""""
|
||||
|
||||
private fun body(vararg responses: String) =
|
||||
"""<?xml version="1.0"?><d:multistatus $ns>${responses.joinToString("")}</d:multistatus>"""
|
||||
|
||||
private fun resp(href: String, ctag: String? = null, token: String? = null) = """
|
||||
<d:response>
|
||||
<d:href>$href</d:href>
|
||||
<d:propstat><d:prop>
|
||||
${ctag?.let { "<cs:getctag>$it</cs:getctag>" } ?: ""}
|
||||
${token?.let { "<d:sync-token>$it</d:sync-token>" } ?: ""}
|
||||
</d:prop></d:propstat>
|
||||
</d:response>
|
||||
""".trimIndent()
|
||||
|
||||
@Test fun ctag_signature_is_stable_and_sorted() {
|
||||
val a = CardDavClient.parseCollectionSignature(
|
||||
body(resp("/dav/addressbooks/u/b1/", ctag = "111"), resp("/dav/addressbooks/u/b2/", ctag = "222")),
|
||||
)
|
||||
// порядок ответов не влияет на подпись
|
||||
val b = CardDavClient.parseCollectionSignature(
|
||||
body(resp("/dav/addressbooks/u/b2/", ctag = "222"), resp("/dav/addressbooks/u/b1/", ctag = "111")),
|
||||
)
|
||||
assertEquals(a, b)
|
||||
assertTrue(a.contains("/dav/addressbooks/u/b1/=111"))
|
||||
assertTrue(a.contains("/dav/addressbooks/u/b2/=222"))
|
||||
}
|
||||
|
||||
@Test fun ctag_change_changes_signature() {
|
||||
val before = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "111")))
|
||||
val after = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "999")))
|
||||
assertNotEquals(before, after)
|
||||
}
|
||||
|
||||
@Test fun sync_token_fallback_when_no_ctag() {
|
||||
val sig = CardDavClient.parseCollectionSignature(body(resp("/b1/", token = "http://sabre/sync/42")))
|
||||
assertEquals("/b1/=http://sabre/sync/42", sig)
|
||||
}
|
||||
|
||||
@Test fun empty_when_no_tags() {
|
||||
assertEquals("", CardDavClient.parseCollectionSignature(body(resp("/b1/"))))
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** Handles Decline on incoming Talk call notifications. */
|
||||
/** Handles Decline and ring-timeout on incoming Talk call notifications. */
|
||||
class F7CallActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_DECLINE) return
|
||||
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
|
||||
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
||||
when (intent.action) {
|
||||
ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
||||
ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
|
||||
const val ACTION_CALL_TIMEOUT = "ru.forbion.f7cloud.action.CALL_TIMEOUT"
|
||||
const val EXTRA_NOTIFICATION_ID = "notificationId"
|
||||
const val EXTRA_ROOM_TOKEN = "roomToken"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package ru.forbion.f7cloud.core.push
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
|
||||
private const val KEY_QUEUE = "queue"
|
||||
private const val KEY_ACTIVE_TOKEN = "active_token"
|
||||
private const val KEY_ACTIVE_AT = "active_at"
|
||||
private const val KEY_ACTIVE_CALL = "active_call"
|
||||
const val ACTIVE_NOTIFICATION_ID = 5000
|
||||
private const val MISSED_NOTIFICATION_BASE = 5100
|
||||
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
|
||||
|
||||
/** Входящий звонит 30 с; дальше — авто-сброс и уведомление «Пропущенный звонок». */
|
||||
private const val RING_TIMEOUT_MS = 30_000L
|
||||
|
||||
/** Запас страховочного alarm поверх основного in-process таймера. */
|
||||
private const val TIMEOUT_ALARM_SLACK_MS = 5_000L
|
||||
|
||||
private val lock = Any()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var timeoutRunnable: Runnable? = null
|
||||
private val callEndedListeners = java.util.concurrent.CopyOnWriteArraySet<(String) -> Unit>()
|
||||
|
||||
/** Уведомляет UI (полноэкранный входящий), что активный звонок снят: принят/отклонён/протух. */
|
||||
fun addCallEndedListener(listener: (String) -> Unit) {
|
||||
callEndedListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeCallEndedListener(listener: (String) -> Unit) {
|
||||
callEndedListeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun notifyCallEnded(token: String) {
|
||||
callEndedListeners.forEach { listener ->
|
||||
runCatching { listener(token) }
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueue(
|
||||
context: Context,
|
||||
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
|
||||
if (active.isEmpty()) {
|
||||
setActive(prefs, token)
|
||||
val shown = showNotification(context, call, waiting = 0)
|
||||
if (!shown) {
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
if (shown) {
|
||||
setActive(prefs, call)
|
||||
scheduleTimeout(context, token)
|
||||
}
|
||||
return shown
|
||||
}
|
||||
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
|
||||
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
||||
var ended: String? = null
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||
@@ -88,36 +118,74 @@ object F7IncomingCallQueue {
|
||||
}
|
||||
}
|
||||
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
|
||||
if (queue.length() == 0) {
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
return
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||
val rest = JSONArray()
|
||||
for (i in 1 until queue.length()) {
|
||||
rest.put(queue.get(i))
|
||||
}
|
||||
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||
if (showNotification(context, next, rest.length())) {
|
||||
setActive(prefs, next.roomToken)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to parse queued call", it)
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
}
|
||||
clearActive(prefs)
|
||||
ended = active.takeIf { it.isNotEmpty() }
|
||||
advanceQueueLocked(context, prefs, queue)
|
||||
}
|
||||
ended?.let { notifyCallEnded(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
|
||||
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
|
||||
* Принятые/отклонённые звонки сюда не попадают — их снимает [dismissAndShowNext].
|
||||
*/
|
||||
fun timeoutActive(context: Context, roomToken: String?) {
|
||||
var ended: String? = null
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||
if (active.isEmpty()) return
|
||||
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
|
||||
|
||||
val call = readActiveCall(prefs)
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
clearActive(prefs)
|
||||
ended = active
|
||||
Log.i(TAG, "Call timed out (unanswered): $active")
|
||||
if (call != null) {
|
||||
showMissedNotification(context, call)
|
||||
}
|
||||
advanceQueueLocked(context, prefs, readQueue(prefs))
|
||||
}
|
||||
ended?.let { notifyCallEnded(it) }
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
synchronized(lock) {
|
||||
val prefs = prefs(context)
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
|
||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceQueueLocked(
|
||||
context: Context,
|
||||
prefs: android.content.SharedPreferences,
|
||||
queue: JSONArray,
|
||||
) {
|
||||
if (queue.length() == 0) {
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||
val rest = JSONArray()
|
||||
for (i in 1 until queue.length()) {
|
||||
rest.put(queue.get(i))
|
||||
}
|
||||
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||
if (showNotification(context, next, rest.length())) {
|
||||
setActive(prefs, next)
|
||||
scheduleTimeout(context, next.roomToken)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to parse queued call", it)
|
||||
prefs.edit().remove(KEY_QUEUE).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,13 +411,106 @@ object F7IncomingCallQueue {
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
|
||||
private fun setActive(prefs: android.content.SharedPreferences, call: PendingCall) {
|
||||
prefs.edit()
|
||||
.putString(KEY_ACTIVE_TOKEN, token)
|
||||
.putString(KEY_ACTIVE_TOKEN, call.roomToken)
|
||||
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
||||
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
|
||||
// показать «Пропущенный» с названием комнаты.
|
||||
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun clearActive(prefs: android.content.SharedPreferences) {
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||
}
|
||||
|
||||
private fun readActiveCall(prefs: android.content.SharedPreferences): PendingCall? {
|
||||
val raw = prefs.getString(KEY_ACTIVE_CALL, null) ?: return null
|
||||
return runCatching { PendingCall.fromJson(JSONObject(raw)) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun scheduleTimeout(context: Context, token: String) {
|
||||
val app = context.applicationContext
|
||||
timeoutRunnable?.let(handler::removeCallbacks)
|
||||
val runnable = Runnable { timeoutActive(app, token) }
|
||||
timeoutRunnable = runnable
|
||||
handler.postDelayed(runnable, RING_TIMEOUT_MS)
|
||||
// Страховка: если процесс умрёт до срабатывания таймера, alarm поднимет его и
|
||||
// заменит зависшее уведомление звонка на «Пропущенный». timeoutActive идемпотентен.
|
||||
runCatching {
|
||||
app.getSystemService(AlarmManager::class.java)?.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
System.currentTimeMillis() + RING_TIMEOUT_MS + TIMEOUT_ALARM_SLACK_MS,
|
||||
timeoutAlarmIntent(app, token),
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "Timeout alarm schedule failed", it) }
|
||||
}
|
||||
|
||||
private fun cancelTimeout(context: Context) {
|
||||
timeoutRunnable?.let(handler::removeCallbacks)
|
||||
timeoutRunnable = null
|
||||
runCatching {
|
||||
context.applicationContext.getSystemService(AlarmManager::class.java)
|
||||
?.cancel(timeoutAlarmIntent(context.applicationContext, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun timeoutAlarmIntent(context: Context, token: String?): PendingIntent {
|
||||
val intent = Intent(context, F7CallActionReceiver::class.java).apply {
|
||||
action = F7CallActionReceiver.ACTION_CALL_TIMEOUT
|
||||
if (token != null) {
|
||||
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, token)
|
||||
}
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
ACTIVE_NOTIFICATION_ID + 2,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMissedNotification(context: Context, call: PendingCall) {
|
||||
F7NotificationChannels.ensureAll(context)
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val roomName = call.displayName.ifBlank { call.title }
|
||||
val notificationId = MISSED_NOTIFICATION_BASE + kotlin.math.abs(call.roomToken.hashCode() % 1000)
|
||||
val pending = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
Intent(launch).putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
|
||||
?: android.R.drawable.sym_call_missed
|
||||
// Локскрин — обезличенно (минимум информации, решение владельца); название комнаты
|
||||
// видно после разблокировки.
|
||||
val publicVersion = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
|
||||
.setSmallIcon(iconRes)
|
||||
.setContentTitle("F7cloud")
|
||||
.setContentText(context.getString(R.string.call_missed_public))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.apply { if (pending != null) setContentIntent(pending) }
|
||||
.build()
|
||||
val notification = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
|
||||
.setSmallIcon(iconRes)
|
||||
.setContentTitle(context.getString(R.string.call_missed_title))
|
||||
.setContentText(roomName)
|
||||
.setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.apply { if (pending != null) setContentIntent(pending) }
|
||||
.build()
|
||||
runCatching { manager.notify(notificationId, notification) }
|
||||
.onFailure { Log.w(TAG, "Missed-call notification failed", it) }
|
||||
}
|
||||
|
||||
private fun touchActive(prefs: android.content.SharedPreferences) {
|
||||
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
||||
}
|
||||
@@ -360,7 +521,7 @@ object F7IncomingCallQueue {
|
||||
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
|
||||
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
|
||||
Log.w(TAG, "Clearing stale active call: $active")
|
||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
||||
clearActive(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<string name="call_action_accept">Принять</string>
|
||||
<string name="call_action_decline">Отклонить</string>
|
||||
<string name="incoming_call_subtitle">F7cloud звонок</string>
|
||||
<string name="call_missed_title">Пропущенный звонок</string>
|
||||
<string name="call_missed_public">Вы пропустили звонок</string>
|
||||
<plurals name="call_queue_waiting">
|
||||
<item quantity="one">Ещё %d звонок в очереди</item>
|
||||
<item quantity="few">Ещё %d звонка в очереди</item>
|
||||
|
||||
@@ -17,13 +17,20 @@ Gradle-кэшем, а не контейнер (иначе WebRTC качаетс
|
||||
как у билд-машины: **JDK 21** (`/usr/lib/jvm/java-21-openjdk-amd64`) + **Android SDK 36**
|
||||
(`/opt/android-sdk`, platform android-36, build-tools 36.0.0).
|
||||
|
||||
Регистрация (нужен registration-токен из Gitea → Settings → Actions → Runners, **права Gitea-админа**):
|
||||
Регистрация — **repo-токен** из Gitea → репозиторий `root/f7cloud_mobile` → Settings → Actions →
|
||||
Runners → Create new Runner (НЕ site-admin токен — тот регистрирует раннер общеинстансно и он
|
||||
подхватывает джобы чужих ячеек; проверено — брал `glb_adm/f7_talk`). Метки `self-hosted`+`f7-android`
|
||||
соответствуют `runs-on: [self-hosted, f7-android]`.
|
||||
|
||||
**Развёрнуто на инфре B (2026-07-09):** `act_runner` v0.2.11 + Node 20 (`/opt/nodejs`) + Docker
|
||||
(`docker.io`, нужен act_runner для старта, но джобы идут на **хосте** — метки `:host`, тёплый кэш
|
||||
`/root/.gradle`). Конфиг `/opt/act_runner/config.yaml` (labels `self-hosted:host`,`f7-android:host`,
|
||||
`container.docker_host: "-"`). systemd-сервис `act_runner.service` (enabled, Restart=always,
|
||||
env JAVA_HOME/ANDROID_HOME/PATH+node). Регистрация — repo-scope (только f7cloud_mobile).
|
||||
```bash
|
||||
# на машине раннера (инфра B):
|
||||
act_runner register --instance https://git.f7cloud.ru --token <TOKEN> --labels f7-android
|
||||
act_runner daemon
|
||||
systemctl status act_runner # состояние
|
||||
journalctl -u act_runner -f # логи
|
||||
```
|
||||
Метка `f7-android` соответствует `runs-on: [self-hosted, f7-android]` в workflow.
|
||||
|
||||
## Secrets репозитория (Gitea → Settings → Actions → Secrets)
|
||||
|
||||
@@ -41,7 +48,24 @@ scripts/package-source.sh dist/ # → dist/f7cloud-mobile-v<ver>-<rev>-sourc
|
||||
В CI прикладывается к каждому Gitea Release. До появления раннера — публиковать исходник
|
||||
вручную этим скриптом к каждой распространяемой сборке.
|
||||
|
||||
## Статус
|
||||
## Статус — ⏸️ ОТЛОЖЕНО (2026-07-09)
|
||||
|
||||
Workflow и скрипт готовы и в репо. **Не активны, пока не зарегистрирован раннер** (инфра-шаг ячейки B,
|
||||
нужен Gitea-админ для токена). До активации: сборки — вручную на билд-машине, исходники — скриптом.
|
||||
Раннер развёрнут на инфре B (act_runner 0.2.13 + Node 20 + Docker + systemd), диспетчеризация
|
||||
Gitea работает (джобы доходят до раннера). **НО host-исполнение act_runner 0.2.13 в нашем
|
||||
окружении сломано**: криво резолвит путь к шагам и JS-экшенам —
|
||||
`No such file or directory` на `.cache/act/<id>/act/workflow/0.sh` и `MODULE_NOT_FOUND` на
|
||||
`actions/checkout` (задвоение `hostexecutor/.cache/act`). Обойти на уровне workflow нельзя
|
||||
(ломается сам запуск шага). Workflow **запаркован** (триггер `workflow_dispatch`), сервис
|
||||
раннера остановлен и отключён от автозапуска — чтобы push'и не плодили падающие прогоны.
|
||||
|
||||
**Рабочий путь (когда вернёмся):** Docker-исполнение вместо host —
|
||||
- кастомный образ с JDK 21 + Android SDK 36 (в дефолтном ubuntu их нет);
|
||||
- volume для тёплого `~/.gradle` (vendor/WebRTC);
|
||||
- раннер с меткой на docker-образ (не `:host`); в docker-режиме пути корректны, JS-экшены работают;
|
||||
- регистрировать repo-токеном именно из **`f7cloud_mobile`** (id 24) — при отладке «repo-токены»
|
||||
ошибочно регистрировали к чужому `f7cloud_client` (id 4) или в user-scope; в Gitea осталось
|
||||
~5 дублей раннера `f7-b-android` (удалить в админке).
|
||||
|
||||
**Пока:** сборки и релизы — **вручную** на билд-машине (боевой ключ владельца из
|
||||
`/root/.f7cloud-keys/`), исходники — `scripts/package-source.sh`, Release в Gitea — через API.
|
||||
Что и делаем: релиз `v0.5.123` опубликован (APK + source) вручную.
|
||||
|
||||
@@ -38,4 +38,5 @@ dependencies {
|
||||
implementation libs.core.ktx
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
testImplementation libs.junit
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -388,46 +387,60 @@ private fun CalendarIconButton(
|
||||
fun CalendarMonthGrid(
|
||||
month: YearMonth,
|
||||
selectedDay: LocalDate,
|
||||
daysWithEvents: Set<LocalDate>,
|
||||
eventsByDay: Map<LocalDate, List<CalendarEventItem>>,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
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()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(F7Colors.Surface)
|
||||
.background(Color.White)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
|
||||
.padding(8.dp),
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
weekDayLabels.forEach { label ->
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextMuted,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
days.chunked(7).forEach { week ->
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
week.forEach { day ->
|
||||
val inMonth = day.month == month.month
|
||||
val selected = day == selectedDay
|
||||
val hasEvents = daysWithEvents.contains(day)
|
||||
val dayEvents = eventsByDay[day].orEmpty()
|
||||
CalendarDayCell(
|
||||
day = day.dayOfMonth,
|
||||
inMonth = inMonth,
|
||||
isToday = day == today,
|
||||
selected = selected,
|
||||
hasEvents = hasEvents,
|
||||
preview = dayEvents.firstOrNull()?.summary.orEmpty(),
|
||||
selected = day == selectedDay,
|
||||
events = dayEvents,
|
||||
onClick = { onSelectDay(day) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -443,65 +456,88 @@ private fun CalendarDayCell(
|
||||
inMonth: Boolean,
|
||||
isToday: Boolean,
|
||||
selected: Boolean,
|
||||
hasEvents: Boolean,
|
||||
preview: String,
|
||||
events: List<CalendarEventItem>,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bg = when {
|
||||
selected -> F7Colors.PrimaryLight
|
||||
isToday -> F7Colors.PrimaryLight.copy(alpha = 0.55f)
|
||||
else -> F7Colors.Surface
|
||||
}
|
||||
val borderColor = when {
|
||||
selected -> F7Colors.Primary
|
||||
isToday -> F7Colors.PrimaryDark
|
||||
else -> F7Colors.BorderLight
|
||||
}
|
||||
Box(
|
||||
// Живая тема: ячейка — карточка #FDFDFD r4 с тонкой тенью, высота 120, паддинг 4;
|
||||
// номер дня 14/500 СПРАВА-сверху (чужой месяц — серый); «сегодня» — зелёная плашка
|
||||
// 40×24 r4 с белым числом; выбранный день подсвечиваем #ECF9DE (наша адаптация тапа).
|
||||
Column(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.padding(2.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(bg)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(2.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
!inMonth -> F7Colors.TextMuted
|
||||
selected -> F7Colors.PrimaryDark
|
||||
else -> F7Colors.TextPrimary
|
||||
},
|
||||
.height(120.dp)
|
||||
.shadow(
|
||||
elevation = 1.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
spotColor = F7Colors.Border,
|
||||
)
|
||||
if (hasEvents && inMonth && preview.isNotBlank()) {
|
||||
Text(
|
||||
preview,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.PrimaryDark,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 1.dp),
|
||||
)
|
||||
} else if (hasEvents && inMonth) {
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(if (selected) F7Colors.PrimaryLight else Color(0xFFFDFDFD))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
if (isToday) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(5.dp)
|
||||
.clip(CircleShape)
|
||||
.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(
|
||||
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, muted: Boolean = false) {
|
||||
// Живая тема: пилюля события — фон #ECF9DE, рамка #70B62B, r4, текст 10/12;
|
||||
// вне текущего месяца — серые с белым текстом.
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.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,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalendarWeekView(
|
||||
selectedDay: LocalDate,
|
||||
@@ -793,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,
|
||||
@@ -1057,7 +1095,13 @@ fun CalendarEventDetailSheet(
|
||||
.padding(bottom = 28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(event.summary, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
// Живая тема: заголовки модалок 18/600
|
||||
Text(
|
||||
event.summary,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
CalendarDetailRow(
|
||||
iconUrl = "$base/themes/forbion/images/calendar/event-clock.svg",
|
||||
label = "Время",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Дисковый кэш «сырых» REPORT-ответов CalDAV (события по диапазону), ключуемый
|
||||
* CTag'ом календаря: CTag не изменился → события читаются с диска, сеть не трогаем.
|
||||
*
|
||||
* Храним именно сырой XML (а не сериализованные модели) — переиспользуем боевой
|
||||
* парсер CalDavClient и не заводим хрупкий JSON-маппинг ~20 полей события.
|
||||
* Формат файла: первая строка — CTag на момент загрузки, дальше XML.
|
||||
*/
|
||||
class CalendarEventsCache(private val dir: File) {
|
||||
|
||||
constructor(context: Context) : this(File(context.cacheDir, "caldav_events"))
|
||||
|
||||
/** XML событий, если кэш есть и его CTag совпадает с текущим; иначе null. */
|
||||
fun get(accountKey: String, calendarHref: String, rangeKey: String, ctag: String): String? {
|
||||
if (ctag.isBlank()) return null
|
||||
val f = entryFile(accountKey, calendarHref, rangeKey)
|
||||
if (!f.isFile) return null
|
||||
return runCatching {
|
||||
val text = f.readText()
|
||||
val nl = text.indexOf('\n')
|
||||
if (nl > 0 && text.substring(0, nl) == ctag) text.substring(nl + 1) else null
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun put(accountKey: String, calendarHref: String, rangeKey: String, ctag: String, xml: String) {
|
||||
if (ctag.isBlank()) return
|
||||
runCatching {
|
||||
dir.mkdirs()
|
||||
entryFile(accountKey, calendarHref, rangeKey).writeText("$ctag\n$xml")
|
||||
prune()
|
||||
}
|
||||
}
|
||||
|
||||
/** Чистка устаревших записей (диапазоны уезжают со временем — файлы копятся). */
|
||||
private fun prune(maxAgeMillis: Long = MAX_AGE_MILLIS) {
|
||||
val now = System.currentTimeMillis()
|
||||
dir.listFiles()?.forEach { f ->
|
||||
if (now - f.lastModified() > maxAgeMillis) f.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun entryFile(accountKey: String, calendarHref: String, rangeKey: String): File =
|
||||
File(dir, md5("$accountKey|$calendarHref|$rangeKey") + ".xml")
|
||||
|
||||
private fun md5(s: String): String =
|
||||
MessageDigest.getInstance("MD5").digest(s.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private companion object {
|
||||
const val MAX_AGE_MILLIS = 30L * 24 * 3600 * 1000 // 30 дней
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import java.time.ZoneOffset
|
||||
|
||||
class CalendarRepository(
|
||||
private val apiClient: CalendarApiClient = CalendarApiClient(),
|
||||
// Кэш событий по CTag (null — без кэша, поведение как раньше)
|
||||
private val eventsCache: CalendarEventsCache? = null,
|
||||
) {
|
||||
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
|
||||
|
||||
@@ -38,7 +40,7 @@ class CalendarRepository(
|
||||
val ctx = openDavContext(session)
|
||||
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs)
|
||||
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs, accountKey(session))
|
||||
}
|
||||
|
||||
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
|
||||
@@ -210,13 +212,25 @@ class CalendarRepository(
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
visibleHrefs: Set<String>,
|
||||
accountKey: String,
|
||||
): List<CalendarEventItem> {
|
||||
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
|
||||
val rangeKey = "${rangeStart.epochSecond}-${rangeEnd.epochSecond}"
|
||||
val events = mutableListOf<DavEvent>()
|
||||
var successCount = 0
|
||||
var lastError: String? = null
|
||||
for (cal in calendars.take(12)) {
|
||||
runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) }
|
||||
runCatching {
|
||||
// CTag календаря не менялся → сырой REPORT-ответ берём с диска, сеть не трогаем
|
||||
val cachedXml = eventsCache?.get(accountKey, cal.href, rangeKey, cal.ctag)
|
||||
if (cachedXml != null) {
|
||||
CalDavClient.parseEventsXml(cachedXml, cal)
|
||||
} else {
|
||||
val xml = CalDavClient.queryEventsRawXml(ctx.client, cal, rangeStart, rangeEnd)
|
||||
eventsCache?.put(accountKey, cal.href, rangeKey, cal.ctag, xml)
|
||||
CalDavClient.parseEventsXml(xml, cal)
|
||||
}
|
||||
}
|
||||
.onSuccess { successCount++; events += it }
|
||||
.onFailure { lastError = it.message }
|
||||
}
|
||||
@@ -226,6 +240,9 @@ class CalendarRepository(
|
||||
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
|
||||
}
|
||||
|
||||
private fun accountKey(session: AuthSession): String =
|
||||
"${session.serverUrl}|${session.username}"
|
||||
|
||||
private fun DavEvent.toItem() = CalendarEventItem(
|
||||
uid = uid,
|
||||
href = href,
|
||||
|
||||
@@ -343,7 +343,6 @@ fun CalendarScreen(
|
||||
CalendarMonthGrid(
|
||||
month = state.visibleMonth,
|
||||
selectedDay = state.selectedDay,
|
||||
daysWithEvents = vm.daysWithEvents(),
|
||||
eventsByDay = eventsByDay,
|
||||
onSelectDay = vm::selectDay,
|
||||
)
|
||||
|
||||
@@ -62,7 +62,9 @@ data class CalendarUiState(
|
||||
|
||||
class CalendarViewModel(
|
||||
context: Context,
|
||||
private val repository: CalendarRepository = CalendarRepository(),
|
||||
private val repository: CalendarRepository = CalendarRepository(
|
||||
eventsCache = CalendarEventsCache(context.applicationContext),
|
||||
),
|
||||
) : ViewModel() {
|
||||
private val appContext = context.applicationContext
|
||||
private val deviceClient = DeviceCalendarClient(appContext)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/**
|
||||
* Кэш «сырых» REPORT-ответов CalDAV: CTag совпал → XML с диска; изменился/пусто → null.
|
||||
*/
|
||||
class CalendarEventsCacheTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private fun cache() = CalendarEventsCache(tmp.newFolder("caldav"))
|
||||
|
||||
@Test fun hit_when_ctag_matches() {
|
||||
val c = cache()
|
||||
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml>events</xml>")
|
||||
assertEquals("<xml>events</xml>", c.get("acc", "/cal/personal/", "r1", "ctag-1"))
|
||||
}
|
||||
|
||||
@Test fun miss_when_ctag_changed() {
|
||||
val c = cache()
|
||||
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml/>")
|
||||
assertNull(c.get("acc", "/cal/personal/", "r1", "ctag-2"))
|
||||
}
|
||||
|
||||
@Test fun miss_on_blank_ctag_and_unknown_range() {
|
||||
val c = cache()
|
||||
c.put("acc", "/cal/personal/", "r1", "", "<xml/>") // пустой ctag не кэшируется
|
||||
assertNull(c.get("acc", "/cal/personal/", "r1", ""))
|
||||
assertNull(c.get("acc", "/cal/personal/", "other-range", "ctag-1"))
|
||||
}
|
||||
|
||||
@Test fun entries_are_isolated_by_account_calendar_range() {
|
||||
val c = cache()
|
||||
c.put("acc1", "/cal/a/", "r1", "t", "<a1/>")
|
||||
c.put("acc2", "/cal/a/", "r1", "t", "<a2/>")
|
||||
c.put("acc1", "/cal/b/", "r1", "t", "<b1/>")
|
||||
assertEquals("<a1/>", c.get("acc1", "/cal/a/", "r1", "t"))
|
||||
assertEquals("<a2/>", c.get("acc2", "/cal/a/", "r1", "t"))
|
||||
assertEquals("<b1/>", c.get("acc1", "/cal/b/", "r1", "t"))
|
||||
}
|
||||
|
||||
@Test fun multiline_xml_preserved() {
|
||||
val c = cache()
|
||||
val xml = "<multistatus>\n <response>\n <href>/e1.ics</href>\n </response>\n</multistatus>"
|
||||
c.put("acc", "/cal/", "r", "ct", xml)
|
||||
assertEquals(xml, c.get("acc", "/cal/", "r", "ct"))
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,18 @@ class ContactsRepository(context: Context) {
|
||||
) {
|
||||
return dao.getAll(key).map { it.toItem() }
|
||||
}
|
||||
// Дешёвая проверка CTag: если адресные книги не менялись (и бэкфиллы сделаны) —
|
||||
// полный PROPFIND всех vCard не нужен, отдаём кэш. Это ускоряет pull-to-refresh.
|
||||
val needsBackfill = needsPhotoBackfill || needsDetailsBackfill
|
||||
val client = authedClient(session)
|
||||
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||
val currentSig = CardDavClient.collectionSignature(client, session.serverUrl, userId)
|
||||
if (!needsBackfill && currentSig != null &&
|
||||
currentSig == prefs.getString(ctagKey(key), null)
|
||||
) {
|
||||
prefs.edit().putLong(lastSyncKey(key), System.currentTimeMillis()).apply()
|
||||
return dao.getAll(key).map { it.toItem() }
|
||||
}
|
||||
val remote = fetchRemoteContacts(session)
|
||||
val entities = remote.map { contact ->
|
||||
ContactEntity(
|
||||
@@ -95,11 +107,12 @@ class ContactsRepository(context: Context) {
|
||||
)
|
||||
}
|
||||
dao.replaceAll(key, entities)
|
||||
prefs.edit()
|
||||
val editor = prefs.edit()
|
||||
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
||||
.putBoolean(photoSyncDoneKey(key), true)
|
||||
.putBoolean(detailsSyncDoneKey(key), true)
|
||||
.apply()
|
||||
if (currentSig != null) editor.putString(ctagKey(key), currentSig) else editor.remove(ctagKey(key))
|
||||
editor.apply()
|
||||
return entities.map { it.toItem() }
|
||||
}
|
||||
|
||||
@@ -190,6 +203,8 @@ class ContactsRepository(context: Context) {
|
||||
|
||||
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
|
||||
|
||||
private fun ctagKey(accountKey: String) = "ctag_$accountKey"
|
||||
|
||||
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
|
||||
|
||||
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
|
||||
|
||||
@@ -21,6 +21,8 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -48,8 +50,10 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactsScreen(
|
||||
session: AuthSession,
|
||||
@@ -75,27 +79,40 @@ fun ContactsScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.contacts.isEmpty(),
|
||||
error = state.error,
|
||||
onErrorRetry = { vm.refresh(session, force = true) },
|
||||
) {
|
||||
ContactsSearchBar(
|
||||
serverUrl = session.serverUrl,
|
||||
query = state.searchQuery,
|
||||
onQueryChange = vm::setSearchQuery,
|
||||
)
|
||||
if (state.syncing && state.contacts.isNotEmpty()) {
|
||||
Text(
|
||||
"Обновление…",
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
|
||||
// панели, теперь — из шапки, панель стала навигационной)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ContactsSearchBar(
|
||||
serverUrl = session.serverUrl,
|
||||
query = state.searchQuery,
|
||||
onQueryChange = vm::setSearchQuery,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||
ContactListRow(
|
||||
contact = contact,
|
||||
onClick = { vm.openContact(contact) },
|
||||
)
|
||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.syncing && state.contacts.isNotEmpty(),
|
||||
onRefresh = { vm.refresh(session, force = true) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||
ContactListRow(
|
||||
contact = contact,
|
||||
onClick = { vm.openContact(contact) },
|
||||
)
|
||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,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,
|
||||
@@ -282,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,
|
||||
@@ -297,12 +316,11 @@ private fun ContactsSearchBar(
|
||||
serverUrl: String,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.shadow(2.dp, RoundedCornerShape(100.dp), spotColor = Color(0xFFCBCBCB))
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
|
||||
@@ -99,14 +99,16 @@ class ContactsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(session: AuthSession, showLoading: Boolean = false) {
|
||||
fun refresh(session: AuthSession, showLoading: Boolean = false, force: Boolean = false) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (showLoading && _state.value.contacts.isEmpty()) {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
} else {
|
||||
_state.update { it.copy(syncing = true, error = null) }
|
||||
}
|
||||
runCatching { repository.syncContacts(session) }
|
||||
// force (pull-to-refresh) с CTag-проверкой дешёвый: 1 лёгкий PROPFIND,
|
||||
// полная закачка vCard — только если адресные книги реально менялись
|
||||
runCatching { repository.syncContacts(session, force = force) }
|
||||
.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -17,6 +20,7 @@ import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeckScreen(
|
||||
session: AuthSession,
|
||||
@@ -57,23 +61,33 @@ fun DeckScreen(
|
||||
},
|
||||
) {
|
||||
val detail = state.boardDetail
|
||||
if (detail != null) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(detail.stacks, key = { it.id }) { stack ->
|
||||
F7ListCard {
|
||||
Text(stack.title, style = MaterialTheme.typography.titleSmall)
|
||||
stack.cards.forEach { card ->
|
||||
val prefix = if (card.done) "✓ " else "• "
|
||||
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.loading && (state.boards.isNotEmpty() || detail != null),
|
||||
onRefresh = {
|
||||
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (detail != null) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(detail.stacks, key = { it.id }) { stack ->
|
||||
F7ListCard {
|
||||
Text(stack.title, style = MaterialTheme.typography.titleSmall)
|
||||
stack.cards.forEach { card ->
|
||||
val prefix = if (card.done) "✓ " else "• "
|
||||
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.boards, key = { it.id }) { board ->
|
||||
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
||||
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.boards, key = { it.id }) { board ->
|
||||
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
|
||||
Text(board.title, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +16,21 @@ 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
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -49,6 +56,7 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
@@ -56,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
|
||||
@@ -93,6 +102,7 @@ fun FilesNavigationSidebar(
|
||||
onToggleTreePath: (String) -> Unit,
|
||||
onToggleSection: (String) -> Unit,
|
||||
onWebOnlyClick: (String) -> Unit,
|
||||
onCreateClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
AnimatedVisibility(
|
||||
@@ -114,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",
|
||||
@@ -221,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -295,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,
|
||||
@@ -303,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,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)
|
||||
@@ -549,7 +613,6 @@ fun FilesSearchBar(
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
@@ -559,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,
|
||||
) {
|
||||
@@ -571,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,
|
||||
@@ -610,6 +673,16 @@ fun FilesSearchBar(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Очистить поиск",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable { onQueryChange("") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -723,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,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)
|
||||
@@ -813,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,
|
||||
@@ -835,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,
|
||||
@@ -844,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,
|
||||
@@ -869,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(
|
||||
@@ -885,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(
|
||||
@@ -946,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(
|
||||
@@ -998,83 +1129,141 @@ fun FilesDetailsSheet(
|
||||
loading: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onTabChange: (FileDetailsTab) -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onWebOnlyAction: (String) -> Unit,
|
||||
) {
|
||||
if (item == null) return
|
||||
val base = serverUrl.trimEnd('/')
|
||||
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(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onDismiss),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = "Закрыть",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Заголовок 18/600 + звезда 20 справа (живая тема)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val metaParts = buildList {
|
||||
item.size?.let { add(formatFileSize(it)) }
|
||||
item.lastModified?.let {
|
||||
add(
|
||||
SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)),
|
||||
)
|
||||
}
|
||||
add("Владелец $ownerLabel")
|
||||
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(
|
||||
metaParts.joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 2,
|
||||
ownerLabel,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("✕", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
val dateSize = buildList {
|
||||
item.lastModified?.let {
|
||||
add(SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)))
|
||||
}
|
||||
item.size?.let { add(formatFileSize(it)) }
|
||||
}
|
||||
if (dateSize.isNotEmpty()) {
|
||||
Text(
|
||||
dateSize.joinToString(" / "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
FilesDetailsTabButton(
|
||||
serverUrl = serverUrl,
|
||||
label = "Общий доступ",
|
||||
iconPath = "files/many-person-gray.svg",
|
||||
selected = tab == FileDetailsTab.Sharing,
|
||||
onClick = { onTabChange(FileDetailsTab.Sharing) },
|
||||
)
|
||||
FilesDetailsTabButton(
|
||||
serverUrl = serverUrl,
|
||||
label = "События",
|
||||
iconPath = "files/grid-files-gray.svg",
|
||||
FilesDetailsTabPill(
|
||||
iconAsset = "comment-icon-black.svg",
|
||||
contentDescription = "Обсуждение",
|
||||
selected = tab == FileDetailsTab.Events,
|
||||
onClick = { onTabChange(FileDetailsTab.Events) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilesDetailsTabPill(
|
||||
iconAsset = "people-icon-black.svg",
|
||||
contentDescription = "Общий доступ",
|
||||
selected = tab == FileDetailsTab.Sharing,
|
||||
onClick = { onTabChange(FileDetailsTab.Sharing) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
color = F7Colors.Border.copy(alpha = 0.5f),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -1117,42 +1306,32 @@ fun FilesDetailsSheet(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilesDetailsTabButton(
|
||||
serverUrl: String,
|
||||
label: String,
|
||||
iconPath: String,
|
||||
private fun FilesDetailsTabPill(
|
||||
iconAsset: String,
|
||||
contentDescription: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
Column(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
// Живая тема: пилюля 30dp r100; активная — фон #ECF9DE, рамка #70B62B 20%; иконка темы 16.
|
||||
val shape = RoundedCornerShape(100.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(30.dp)
|
||||
.clip(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,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "$base/themes/forbion/images/$iconPath",
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = if (selected) F7Colors.TextPrimary else F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(2.dp)
|
||||
.width(72.dp)
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(if (selected) F7Colors.Primary else Color.Transparent),
|
||||
AsyncImage(
|
||||
model = FileIcons.chromeAsset(iconAsset),
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1348,8 +1527,24 @@ private fun FilesEventsTabContent(
|
||||
modifier = Modifier.weight(1f),
|
||||
onEnter = onSubmitComment,
|
||||
)
|
||||
TextButton(onClick = onSubmitComment) {
|
||||
Text("→", fontSize = 20.sp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
|
||||
),
|
||||
)
|
||||
.clickable(onClick = onSubmitComment),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = "Отправить",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
@@ -171,6 +172,7 @@ fun FilesScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
loading = state.loading && displayItems.isEmpty(),
|
||||
error = state.error,
|
||||
onErrorRetry = { vm.refreshCurrent(session) },
|
||||
headerActions = {},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -189,6 +191,8 @@ fun FilesScreen(
|
||||
FilesViewToggleButton(viewMode) {
|
||||
viewMode = if (viewMode == FilesViewMode.List) FilesViewMode.Grid else FilesViewMode.List
|
||||
}
|
||||
// Кнопки создания в шапке нет (живая тема): «Создать или загрузить» —
|
||||
// градиентная кнопка сверху панели навигации.
|
||||
}
|
||||
// Хлебные крошки
|
||||
val crumbSegments = state.currentPath.split("/").filter { it.isNotBlank() }
|
||||
@@ -198,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
|
||||
@@ -318,6 +328,10 @@ fun FilesScreen(
|
||||
vm.showWebOnlyMessage(title)
|
||||
onSidebarOpenChange(false)
|
||||
},
|
||||
onCreateClick = {
|
||||
onSidebarOpenChange(false)
|
||||
showCreateMenu = true
|
||||
},
|
||||
)
|
||||
|
||||
FilesCreateMenu(
|
||||
@@ -360,6 +374,7 @@ fun FilesScreen(
|
||||
loading = state.detailsLoading,
|
||||
onDismiss = vm::hideDetails,
|
||||
onTabChange = vm::setDetailsTab,
|
||||
onToggleFavorite = { state.detailsItem?.let { vm.toggleFavorite(session, it) } },
|
||||
onWebOnlyAction = vm::showWebOnlyMessage,
|
||||
)
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -710,10 +737,11 @@ private fun tagChipColors(tag: MailTag): TagChipColors {
|
||||
text = parsed.copy(alpha = 0.95f),
|
||||
)
|
||||
}
|
||||
// Точные цвета чипов из макета «Почта · Список сообщений» (Figma, сняты пипеткой)
|
||||
return when (tag.displayName.lowercase(Locale("ru"))) {
|
||||
"работа", "work" -> TagChipColors(Color(0xFFE8F5D6), Color(0xFF5A7A2E))
|
||||
"позже", "later" -> TagChipColors(Color(0xFFF5F0E6), Color(0xFF8A7345))
|
||||
"личное", "personal" -> TagChipColors(Color(0xFFEDE8F5), Color(0xFF6A558A))
|
||||
"работа", "work" -> TagChipColors(Color(0xFFDAEDD6), Color(0xFF28A413))
|
||||
"позже", "later" -> TagChipColors(Color(0xFFF1EEE0), Color(0xFFC1B464))
|
||||
"личное", "personal" -> TagChipColors(Color(0xFFEFE4FB), Color(0xFFB574F7))
|
||||
else -> TagChipColors(F7Colors.SurfaceMuted, F7Colors.TextSecondary)
|
||||
}
|
||||
}
|
||||
@@ -954,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) }
|
||||
@@ -986,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,
|
||||
@@ -1098,6 +1128,8 @@ fun MailMessageSenderCard(
|
||||
onEditTags = onEditTags,
|
||||
onMove = onMove,
|
||||
onSnooze = onSnooze,
|
||||
onCreateTask = onCreateTask,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1157,6 +1189,8 @@ fun MailMessageActionsMenu(
|
||||
onEditTags: () -> Unit,
|
||||
onMove: () -> Unit,
|
||||
onSnooze: () -> Unit,
|
||||
onCreateTask: (() -> Unit)? = null,
|
||||
onCreateEvent: (() -> Unit)? = null,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
@@ -1210,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()
|
||||
@@ -1301,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 {
|
||||
@@ -1316,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)
|
||||
@@ -70,6 +70,14 @@ fun MailMessageBodyView(
|
||||
settings.domStorageEnabled = false
|
||||
settings.loadsImagesAutomatically = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
// Вписывать письмо по ширине экрана: уважать viewport width=device-width
|
||||
// и масштабировать широкие письма (таблицы Битрикс/рассылок) в обзорный вид,
|
||||
// иначе WebView раскладывает по ~980px и контент уезжает за правый край.
|
||||
settings.useWideViewPort = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.setSupportZoom(true)
|
||||
settings.builtInZoomControls = true
|
||||
settings.displayZoomControls = false
|
||||
isVerticalScrollBarEnabled = true
|
||||
isNestedScrollingEnabled = true
|
||||
setBackgroundColor(0x00000000)
|
||||
|
||||
@@ -3,16 +3,24 @@ package ru.forbion.f7cloud.feature.mail
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -31,6 +39,13 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.feature.contacts.ContactItem
|
||||
import ru.forbion.f7cloud.feature.contacts.ContactListRow
|
||||
|
||||
/**
|
||||
* Поле получателей с чипами — как в живой теме (_mobile-mail.css):
|
||||
* подтверждённые адреса (сегменты строки через запятую) — пилюли 26dp
|
||||
* (фон #70B62B 12%, рамка 25%, текст 13/18, крестик 14), хвост — инлайн-ввод.
|
||||
* Значение остаётся строкой «a, b, черновик» — VM/parseRecipients не меняются.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MailRecipientComposeField(
|
||||
label: String,
|
||||
@@ -43,6 +58,19 @@ fun MailRecipientComposeField(
|
||||
trailingIconUrl: String? = null,
|
||||
onTrailingClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val parts = value.split(',').map { it.trim() }
|
||||
val draft = if (value.trimEnd().endsWith(",")) "" else parts.lastOrNull().orEmpty()
|
||||
val committed = (if (draft.isEmpty()) parts else parts.dropLast(1)).filter { it.isNotBlank() }
|
||||
|
||||
fun rebuild(newCommitted: List<String>, newDraft: String): String {
|
||||
val head = newCommitted.joinToString(", ")
|
||||
return when {
|
||||
head.isEmpty() -> newDraft
|
||||
newDraft.isEmpty() -> "$head, "
|
||||
else -> "$head, $newDraft"
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -51,27 +79,55 @@ fun MailRecipientComposeField(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
.heightIn(min = 40.dp)
|
||||
.padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
FlowRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
|
||||
cursorBrush = SolidColor(F7Colors.Primary),
|
||||
decorationBox = { inner ->
|
||||
if (value.isEmpty()) {
|
||||
Text(label, color = F7Colors.TextSecondary, fontSize = 15.sp)
|
||||
}
|
||||
inner()
|
||||
},
|
||||
)
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
committed.forEachIndexed { index, recipient ->
|
||||
RecipientChip(
|
||||
text = recipient,
|
||||
onRemove = {
|
||||
val rest = committed.toMutableList().also { it.removeAt(index) }
|
||||
onValueChange(rebuild(rest, draft))
|
||||
},
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = draft,
|
||||
onValueChange = { new -> onValueChange(rebuild(committed, new)) },
|
||||
modifier = Modifier
|
||||
.widthIn(min = 80.dp)
|
||||
.weight(1f)
|
||||
.height(26.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
),
|
||||
cursorBrush = SolidColor(F7Colors.Primary),
|
||||
decorationBox = { inner ->
|
||||
Box(contentAlignment = Alignment.CenterStart, modifier = Modifier.height(26.dp)) {
|
||||
if (draft.isEmpty() && committed.isEmpty()) {
|
||||
Text(label, color = F7Colors.TextSecondary, fontSize = 14.sp)
|
||||
}
|
||||
inner()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (trailingIconUrl != null && onTrailingClick != null) {
|
||||
AsyncImage(
|
||||
model = trailingIconUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp)
|
||||
.size(16.dp)
|
||||
.clickable(onClick = onTrailingClick),
|
||||
contentScale = ContentScale.Fit,
|
||||
@@ -107,3 +163,39 @@ fun MailRecipientComposeField(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientChip(text: String, onRemove: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(26.dp)
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(F7Colors.Primary.copy(alpha = 0.12f))
|
||||
.border(1.dp, F7Colors.Primary.copy(alpha = 0.25f), RoundedCornerShape(100.dp))
|
||||
.padding(start = 6.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Убрать получателя",
|
||||
tint = F7Colors.TextPrimary.copy(alpha = 0.5f),
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clickable(
|
||||
interactionSource = androidx.compose.runtime.remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onRemove,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,7 +967,115 @@ class MailRepository {
|
||||
}.sortedByDescending { it.updatedAt }
|
||||
}
|
||||
|
||||
// --- Фильтры почты: официальный API NC Mail (тот же, что у веб-модалки «Новый фильтр») ---
|
||||
// GET/PUT /apps/mail/api/filter/{accountId}; сервер сам сериализует в sieve-скрипт
|
||||
// (секция «### Nextcloud Mail: Filters ###»), правила ОБЩИЕ между вебом и приложением.
|
||||
|
||||
fun getMailFilters(session: AuthSession, accountId: Int): List<MailFilterRule> {
|
||||
val client = authedClient(session)
|
||||
val array = getJsonArray(client, "${apiBase(session)}/filter/$accountId")
|
||||
return buildList {
|
||||
for (i in 0 until array.length()) {
|
||||
array.optJSONObject(i)?.let { add(MailFilterRule(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun putMailFilters(session: AuthSession, accountId: Int, filters: List<MailFilterRule>) {
|
||||
val client = authedClient(session)
|
||||
val payload = JSONObject()
|
||||
.put("filters", JSONArray().apply { filters.forEach { put(it.raw) } })
|
||||
.toString()
|
||||
val request = Request.Builder()
|
||||
.url("${apiBase(session)}/filter/$accountId")
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.put(payload.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Filter API HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Правило фильтра почты. Держим исходный JSON (raw) целиком, чтобы при правке
|
||||
* простых полей не терять незнакомые поля сложных правил, созданных в вебе.
|
||||
*/
|
||||
class MailFilterRule(val raw: JSONObject) {
|
||||
val name: String get() = raw.optString("name")
|
||||
val enable: Boolean get() = raw.optBoolean("enable", true)
|
||||
val operator: String get() = raw.optString("operator", "allof")
|
||||
val priority: Int get() = raw.optInt("priority", 10)
|
||||
|
||||
val tests: List<Triple<String, String, List<String>>>
|
||||
get() = raw.optJSONArray("tests")?.let { arr ->
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val t = arr.optJSONObject(i) ?: continue
|
||||
val values = t.optJSONArray("values")?.let { v ->
|
||||
(0 until v.length()).map { v.optString(it) }
|
||||
}.orEmpty()
|
||||
add(Triple(t.optString("field"), t.optString("operator", "contains"), values))
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
val actions: List<JSONObject>
|
||||
get() = raw.optJSONArray("actions")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { arr.optJSONObject(it) }
|
||||
}.orEmpty()
|
||||
|
||||
/** Простое правило (1 тест, действия из нашего конструктора) — редактируемо инлайн. */
|
||||
val isSimple: Boolean
|
||||
get() = tests.size == 1 && actions.all {
|
||||
it.optString("type") in setOf("fileinto", "addflag", "addsystemflag")
|
||||
}
|
||||
|
||||
fun withEnable(value: Boolean): MailFilterRule =
|
||||
MailFilterRule(JSONObject(raw.toString()).put("enable", value))
|
||||
|
||||
companion object {
|
||||
fun simple(
|
||||
name: String,
|
||||
field: String,
|
||||
contains: String,
|
||||
action: String,
|
||||
targetFolder: String,
|
||||
priority: Int = 10,
|
||||
): MailFilterRule {
|
||||
val actions = JSONArray()
|
||||
when (action) {
|
||||
"markread" -> actions.put(
|
||||
JSONObject().put("type", "addsystemflag").put("flag", "\\Seen"),
|
||||
)
|
||||
else -> actions.put(
|
||||
JSONObject().put("type", "fileinto").put("mailbox", targetFolder),
|
||||
)
|
||||
}
|
||||
return MailFilterRule(
|
||||
JSONObject()
|
||||
.put("name", name)
|
||||
.put("enable", true)
|
||||
.put("operator", "allof")
|
||||
.put("priority", priority)
|
||||
.put(
|
||||
"tests",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("field", field)
|
||||
.put("operator", "contains")
|
||||
.put("values", JSONArray().put(contains)),
|
||||
),
|
||||
)
|
||||
.put("actions", actions),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -46,6 +48,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import androidx.activity.compose.BackHandler
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ErrorState
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
|
||||
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -246,6 +249,23 @@ fun MailScreen(
|
||||
onSettingsOpenChange(false)
|
||||
},
|
||||
onSwipeBack = { mailNavigateBack() },
|
||||
onOpenSieveFilters = { accountId -> vm.openSieveFilters(session, accountId) },
|
||||
)
|
||||
|
||||
MailSieveFiltersSheet(
|
||||
visible = state.sieveAccountId != null,
|
||||
rules = state.sieveRules,
|
||||
folders = state.mailboxes
|
||||
.filter { it.accountId == state.sieveAccountId && !it.isInbox && it.specialRole.isNullOrBlank() }
|
||||
.map { it.displayName },
|
||||
loading = state.sieveLoading,
|
||||
saving = state.sieveSaving,
|
||||
error = state.sieveError,
|
||||
onDismiss = { vm.closeSieveFilters() },
|
||||
onAdd = { rule -> vm.addFilterRule(session, rule) },
|
||||
onUpdate = { index, rule -> vm.updateFilterRule(session, index, rule) },
|
||||
onDelete = { index -> vm.deleteFilterRule(session, index) },
|
||||
onToggle = { index, enable -> vm.toggleFilterRule(session, index, enable) },
|
||||
)
|
||||
|
||||
BackHandler(enabled = mailCanGoBack) {
|
||||
@@ -462,6 +482,34 @@ fun MailScreen(
|
||||
openScheduledReplyForMessage(detail, option)
|
||||
},
|
||||
)
|
||||
// «Создать задачу/событие из письма» — паритет живого веба:
|
||||
// префилл темой письма и отправителем.
|
||||
val createDefaults = state.messageDetail?.let { detail ->
|
||||
detail.subject.ifBlank { "(без темы)" } to
|
||||
"Из письма от ${detail.from.ifBlank { detail.fromEmail }}"
|
||||
}
|
||||
MailCreateTaskSheet(
|
||||
visible = state.createTaskSheetOpen,
|
||||
taskLists = state.createTargets,
|
||||
defaultSummary = createDefaults?.first.orEmpty(),
|
||||
defaultDescription = createDefaults?.second.orEmpty(),
|
||||
creating = state.creatingFromMessage,
|
||||
onDismiss = vm::dismissCreateSheets,
|
||||
onCreate = { listHref, summary, description, due ->
|
||||
vm.createTaskFromMessage(session, listHref, summary, description, due)
|
||||
},
|
||||
)
|
||||
MailCreateEventSheet(
|
||||
visible = state.createEventSheetOpen,
|
||||
calendars = state.createTargets,
|
||||
defaultTitle = createDefaults?.first.orEmpty(),
|
||||
defaultDescription = createDefaults?.second.orEmpty(),
|
||||
creating = state.creatingFromMessage,
|
||||
onDismiss = vm::dismissCreateSheets,
|
||||
onCreate = { calHref, title, description, date, start, end, allDay ->
|
||||
vm.createEventFromMessage(session, calHref, title, description, date, start, end, allDay)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
MailInboxScreen(
|
||||
@@ -485,6 +533,8 @@ fun MailScreen(
|
||||
onOutboxRetry = { vm.retryOutboxMessage(session, it) },
|
||||
onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
|
||||
onLoadMore = { vm.loadMore(session) },
|
||||
refreshing = state.loading,
|
||||
onRefresh = { vm.load(session, forceRefresh = true) },
|
||||
)
|
||||
}
|
||||
MailNavigationSidebar(
|
||||
@@ -520,6 +570,7 @@ fun MailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MailInboxScreen(
|
||||
session: AuthSession,
|
||||
@@ -542,6 +593,8 @@ private fun MailInboxScreen(
|
||||
onOutboxRetry: (Int) -> Unit,
|
||||
onOutboxDelete: (Int) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
refreshing: Boolean = false,
|
||||
onRefresh: () -> Unit = {},
|
||||
) {
|
||||
val listItems = remember(messages) { buildMailInboxListItems(messages) }
|
||||
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
|
||||
@@ -592,14 +645,14 @@ private fun MailInboxScreen(
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Box(
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing && !loading,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -633,11 +686,25 @@ private fun MailInboxScreen(
|
||||
}
|
||||
return@Box
|
||||
}
|
||||
if (messages.isEmpty()) {
|
||||
Box(
|
||||
if (messages.isEmpty() && !error.isNullOrBlank()) {
|
||||
F7ErrorState(
|
||||
message = error,
|
||||
onRetry = onRefresh,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
)
|
||||
} else if (messages.isEmpty()) {
|
||||
// Живая тема: пустая папка — цветная иконка почты 64dp + текст 16/20
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
coil.compose.AsyncImage(
|
||||
model = "file:///android_asset/mailchrome/mail-icon-color.svg",
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
if (searchQuery.isNotBlank() || searchParams.isActive()) {
|
||||
"Ничего не найдено"
|
||||
@@ -666,7 +733,8 @@ private fun MailInboxScreen(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
// Живая тема: зазор между карточками писем 2px
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items(
|
||||
listItems,
|
||||
@@ -705,6 +773,7 @@ private fun MailInboxScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,6 +828,8 @@ private fun MailMessageDetailHeader(
|
||||
onEditTags = onTagsSheetOpen,
|
||||
onMove = onMoveSheetOpen,
|
||||
onSnooze = onSnoozeSheetOpen,
|
||||
onCreateTask = { vm.openCreateTaskSheet(session) },
|
||||
onCreateEvent = { vm.openCreateEventSheet(session) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ fun MailSettingsSheet(
|
||||
onDeleteTextBlock: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSwipeBack: () -> Unit = onDismiss,
|
||||
onOpenSieveFilters: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
if (!visible) return
|
||||
LaunchedEffect(visible, editingAccountId) {
|
||||
@@ -102,6 +103,9 @@ fun MailSettingsSheet(
|
||||
mailboxes = mailboxes.filter { it.accountId == editingAccountId },
|
||||
onBack = onAccountSettingsBack,
|
||||
onSave = onSaveAccountSettings,
|
||||
onOpenSieveFilters = onOpenSieveFilters?.let { open ->
|
||||
{ open(editingAccountId) }
|
||||
},
|
||||
)
|
||||
} else {
|
||||
MailAppSettingsScreen(
|
||||
@@ -517,6 +521,7 @@ private fun MailAccountSettingsScreen(
|
||||
mailboxes: List<MailMailbox>,
|
||||
onBack: () -> Unit,
|
||||
onSave: (MailAccountSettings) -> Unit,
|
||||
onOpenSieveFilters: (() -> Unit)? = null,
|
||||
) {
|
||||
var draft by remember { mutableStateOf<MailAccountSettings?>(null) }
|
||||
LaunchedEffect(settings) {
|
||||
@@ -567,6 +572,27 @@ private fun MailAccountSettingsScreen(
|
||||
draft!!.quotaPercentage?.let { quota ->
|
||||
MailSettingsInfoRow("Квота", "$quota%")
|
||||
}
|
||||
onOpenSieveFilters?.let { open ->
|
||||
// Sieve-фильтры (паритет живого веба)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(F7Colors.SurfaceMuted)
|
||||
.clickable(onClick = open)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Фильтры",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text("›", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
}
|
||||
MailSettingsTextArea(
|
||||
label = "Подпись",
|
||||
value = draft!!.signature,
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package ru.forbion.f7cloud.feature.mail
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
|
||||
private val FIELD_OPTIONS = listOf("from" to "От", "subject" to "Тема", "to" to "Кому")
|
||||
private val ACTION_OPTIONS = listOf(
|
||||
"fileinto" to "Переместить в папку",
|
||||
"markread" to "Пометить прочитанным",
|
||||
)
|
||||
|
||||
/** Пилюля-переключатель варианта (чипы живой темы: актив — #ECF9DE). */
|
||||
@Composable
|
||||
private fun OptionChip(text: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = 25.dp)
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(if (selected) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ruleSummary(rule: MailFilterRule): String {
|
||||
val tests = rule.tests.joinToString("; ") { (field, op, values) ->
|
||||
val fieldLabel = FIELD_OPTIONS.firstOrNull { it.first == field }?.second ?: field
|
||||
"$fieldLabel $op «${values.joinToString(", ")}»"
|
||||
}
|
||||
val actions = rule.actions.joinToString("; ") { action ->
|
||||
when (action.optString("type")) {
|
||||
"fileinto" -> "в папку «${action.optString("mailbox")}»"
|
||||
"addflag", "addsystemflag" ->
|
||||
if (action.optString("flag").contains("Seen")) "пометить прочитанным"
|
||||
else "флаг «${action.optString("flag")}»"
|
||||
"keep" -> "оставить"
|
||||
"stop" -> "стоп"
|
||||
else -> action.optString("type")
|
||||
}
|
||||
}
|
||||
return "$tests → $actions"
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтры почты — общие с веб-версией (официальный API NC Mail).
|
||||
* Список всех правил (вкл/выкл, удаление), редактирование простых, конструктор нового.
|
||||
*/
|
||||
@Composable
|
||||
fun MailSieveFiltersSheet(
|
||||
visible: Boolean,
|
||||
rules: List<MailFilterRule>,
|
||||
folders: List<String>,
|
||||
loading: Boolean,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (MailFilterRule) -> Unit,
|
||||
onUpdate: (Int, MailFilterRule) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
onToggle: (Int, Boolean) -> Unit,
|
||||
) {
|
||||
if (!visible) return
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
var field by rememberSaveable { mutableStateOf("subject") }
|
||||
var contains by rememberSaveable { mutableStateOf("") }
|
||||
var action by rememberSaveable { mutableStateOf("fileinto") }
|
||||
var targetFolder by rememberSaveable(folders) { mutableStateOf(folders.firstOrNull().orEmpty()) }
|
||||
// -1 = создание нового; иначе — индекс редактируемого правила
|
||||
var editingIndex by rememberSaveable { mutableIntStateOf(-1) }
|
||||
|
||||
fun loadIntoForm(rule: MailFilterRule) {
|
||||
name = rule.name
|
||||
rule.tests.firstOrNull()?.let { (f, _, values) ->
|
||||
field = f
|
||||
contains = values.joinToString(", ")
|
||||
}
|
||||
val act = rule.actions.firstOrNull()
|
||||
action = if (act?.optString("type") == "fileinto") "fileinto" else "markread"
|
||||
if (act?.optString("type") == "fileinto") {
|
||||
targetFolder = act.optString("mailbox")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearForm() {
|
||||
name = ""
|
||||
contains = ""
|
||||
editingIndex = -1
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.94f)
|
||||
.heightIn(max = 660.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"Фильтры",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
when {
|
||||
loading -> Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
else -> {
|
||||
if (rules.isEmpty()) {
|
||||
Text(
|
||||
"Фильтров пока нет.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
rules.forEachIndexed { index, rule ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 36.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(
|
||||
if (editingIndex == index) F7Colors.PrimaryLight else F7Colors.SurfaceMuted,
|
||||
)
|
||||
.clickable(enabled = !saving && rule.isSimple) {
|
||||
editingIndex = index
|
||||
loadIntoForm(rule)
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
rule.name.ifBlank { "Без названия" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
ruleSummary(rule) +
|
||||
if (!rule.isSimple) " (сложное — правится в вебе)" else "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = rule.enable,
|
||||
onCheckedChange = { onToggle(index, it) },
|
||||
enabled = !saving,
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = F7Colors.Primary),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Удалить фильтр",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable(enabled = !saving) {
|
||||
if (editingIndex == index) clearForm()
|
||||
onDelete(index)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
if (editingIndex >= 0) "Изменить фильтр" else "Новый фильтр",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
F7OutlinedField(value = name, onValueChange = { name = it }, label = "Название")
|
||||
Text("Если", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
FIELD_OPTIONS.forEach { (value, label) ->
|
||||
OptionChip(label, selected = field == value) { field = value }
|
||||
}
|
||||
}
|
||||
F7OutlinedField(value = contains, onValueChange = { contains = it }, label = "Содержит")
|
||||
Text("То", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ACTION_OPTIONS.forEach { (value, label) ->
|
||||
OptionChip(label, selected = action == value) { action = value }
|
||||
}
|
||||
}
|
||||
if (action == "fileinto" && folders.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
folders.forEach { folder ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 32.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(
|
||||
if (folder == targetFolder) F7Colors.PrimaryLight else F7Colors.SurfaceMuted,
|
||||
)
|
||||
.clickable { targetFolder = folder }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
folder,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton(
|
||||
if (editingIndex >= 0) "Отменить правку" else "Закрыть",
|
||||
onClick = { if (editingIndex >= 0) clearForm() else onDismiss() },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
F7PrimaryButton(
|
||||
text = when {
|
||||
saving -> "Сохранение…"
|
||||
editingIndex >= 0 -> "Сохранить"
|
||||
else -> "Создать"
|
||||
},
|
||||
onClick = {
|
||||
val rule = MailFilterRule.simple(
|
||||
name = name.trim().ifBlank { "Фильтр" },
|
||||
field = field,
|
||||
contains = contains.trim(),
|
||||
action = action,
|
||||
targetFolder = if (action == "fileinto") targetFolder else "",
|
||||
)
|
||||
if (editingIndex >= 0) onUpdate(editingIndex, rule) else onAdd(rule)
|
||||
clearForm()
|
||||
},
|
||||
enabled = !saving && contains.isNotBlank() &&
|
||||
(action != "fileinto" || targetFolder.isNotBlank()),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
@@ -44,6 +45,17 @@ data class MailUiState(
|
||||
val appSettingsSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
// «Создать задачу/событие из письма» (паритет живого веба)
|
||||
val createTaskSheetOpen: Boolean = false,
|
||||
val createEventSheetOpen: Boolean = false,
|
||||
val createTargets: List<MailTargetList> = emptyList(),
|
||||
val creatingFromMessage: Boolean = false,
|
||||
// Фильтры почты (официальный API NC Mail — общие с вебом)
|
||||
val sieveAccountId: Int? = null,
|
||||
val sieveRules: List<MailFilterRule> = emptyList(),
|
||||
val sieveLoading: Boolean = false,
|
||||
val sieveSaving: Boolean = false,
|
||||
val sieveError: String? = null,
|
||||
)
|
||||
|
||||
class MailViewModel(
|
||||
@@ -995,6 +1007,172 @@ class MailViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
// --- «Создать задачу/событие из письма» (паритет живого веба) ---
|
||||
|
||||
fun openCreateTaskSheet(session: AuthSession) {
|
||||
_state.update { it.copy(createTaskSheetOpen = true, createTargets = emptyList()) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
ru.forbion.f7cloud.feature.tasks.TasksRepository()
|
||||
.listTaskLists(session)
|
||||
.map { MailTargetList(it.href, it.displayName) }
|
||||
}.onSuccess { lists ->
|
||||
_state.update { it.copy(createTargets = lists) }
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(createTaskSheetOpen = false, error = t.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openCreateEventSheet(session: AuthSession) {
|
||||
_state.update { it.copy(createEventSheetOpen = true, createTargets = emptyList()) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
ru.forbion.f7cloud.feature.calendar.CalendarRepository()
|
||||
.listCalendars(session)
|
||||
.map { MailTargetList(it.href, it.displayName) }
|
||||
}.onSuccess { calendars ->
|
||||
_state.update { it.copy(createTargets = calendars) }
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(createEventSheetOpen = false, error = t.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissCreateSheets() {
|
||||
_state.update { it.copy(createTaskSheetOpen = false, createEventSheetOpen = false) }
|
||||
}
|
||||
|
||||
fun createTaskFromMessage(
|
||||
session: AuthSession,
|
||||
listHref: String,
|
||||
summary: String,
|
||||
description: String,
|
||||
due: java.time.LocalDate?,
|
||||
) {
|
||||
_state.update { it.copy(creatingFromMessage = true) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
ru.forbion.f7cloud.feature.tasks.TasksRepository().createTask(
|
||||
session = session,
|
||||
listHref = listHref,
|
||||
summary = summary,
|
||||
dueDate = due,
|
||||
description = description,
|
||||
)
|
||||
}.onSuccess {
|
||||
_state.update {
|
||||
it.copy(creatingFromMessage = false, createTaskSheetOpen = false, error = null)
|
||||
}
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(creatingFromMessage = false, error = t.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createEventFromMessage(
|
||||
session: AuthSession,
|
||||
calendarHref: String,
|
||||
title: String,
|
||||
description: String,
|
||||
date: java.time.LocalDate,
|
||||
startTime: String,
|
||||
endTime: String,
|
||||
allDay: Boolean,
|
||||
) {
|
||||
_state.update { it.copy(creatingFromMessage = true) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
ru.forbion.f7cloud.feature.calendar.CalendarRepository().saveEvent(
|
||||
session,
|
||||
ru.forbion.f7cloud.feature.calendar.CalendarEventDraft(
|
||||
title = title,
|
||||
date = date,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
allDay = allDay,
|
||||
description = description,
|
||||
calendarHref = calendarHref,
|
||||
),
|
||||
)
|
||||
}.onSuccess {
|
||||
_state.update {
|
||||
it.copy(creatingFromMessage = false, createEventSheetOpen = false, error = null)
|
||||
}
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(creatingFromMessage = false, error = t.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Фильтры почты (общие с вебом, API /api/filter/{accountId}) ---
|
||||
|
||||
fun openSieveFilters(session: AuthSession, accountId: Int) {
|
||||
_state.update {
|
||||
it.copy(sieveAccountId = accountId, sieveRules = emptyList(), sieveLoading = true, sieveError = null)
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
repository.getMailFilters(session, accountId)
|
||||
}.onSuccess { rules ->
|
||||
_state.update { it.copy(sieveRules = rules, sieveLoading = false) }
|
||||
}.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(sieveLoading = false, sieveError = t.message ?: "Фильтры недоступны")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSieveFilters() {
|
||||
_state.update { it.copy(sieveAccountId = null, sieveError = null) }
|
||||
}
|
||||
|
||||
fun addFilterRule(session: AuthSession, rule: MailFilterRule) {
|
||||
saveFilterRules(session) { it + rule }
|
||||
}
|
||||
|
||||
fun updateFilterRule(session: AuthSession, index: Int, rule: MailFilterRule) {
|
||||
saveFilterRules(session) { rules ->
|
||||
rules.toMutableList().also { if (index in it.indices) it[index] = rule }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFilterRule(session: AuthSession, index: Int) {
|
||||
saveFilterRules(session) { rules ->
|
||||
rules.toMutableList().also { if (index in it.indices) it.removeAt(index) }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFilterRule(session: AuthSession, index: Int, enable: Boolean) {
|
||||
saveFilterRules(session) { rules ->
|
||||
rules.toMutableList().also {
|
||||
if (index in it.indices) it[index] = it[index].withEnable(enable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveFilterRules(
|
||||
session: AuthSession,
|
||||
transform: (List<MailFilterRule>) -> List<MailFilterRule>,
|
||||
) {
|
||||
val accountId = _state.value.sieveAccountId ?: return
|
||||
// Правки применяем к ТЕКУЩЕМУ локальному списку (он же только что с сервера);
|
||||
// сервер хранит фильтры единым массивом — PUT перезаписывает секцию приложения Mail.
|
||||
val rules = transform(_state.value.sieveRules)
|
||||
_state.update { it.copy(sieveSaving = true, sieveError = null) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
repository.putMailFilters(session, accountId, rules)
|
||||
repository.getMailFilters(session, accountId)
|
||||
}.onSuccess { fresh ->
|
||||
_state.update { it.copy(sieveRules = fresh, sieveSaving = false) }
|
||||
}.onFailure { t ->
|
||||
_state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MARK_READ_DELAY_MS = 2000L
|
||||
private const val FOLDER_CACHE_TTL_MS = 15 * 60 * 1000L
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package ru.forbion.f7cloud.feature.mail
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MailFilterRuleTest {
|
||||
|
||||
@Test
|
||||
fun simple_rule_builds_web_compatible_json() {
|
||||
val rule = MailFilterRule.simple(
|
||||
name = "Работа",
|
||||
field = "subject",
|
||||
contains = "Отчёт",
|
||||
action = "fileinto",
|
||||
targetFolder = "Work",
|
||||
)
|
||||
assertEquals("Работа", rule.name)
|
||||
assertTrue(rule.enable)
|
||||
assertEquals("allof", rule.operator)
|
||||
assertEquals(listOf(Triple("subject", "contains", listOf("Отчёт"))), rule.tests)
|
||||
assertEquals("fileinto", rule.actions.single().optString("type"))
|
||||
assertEquals("Work", rule.actions.single().optString("mailbox"))
|
||||
assertTrue(rule.isSimple)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markread_uses_system_flag() {
|
||||
val rule = MailFilterRule.simple("r", "from", "boss", "markread", "")
|
||||
val action = rule.actions.single()
|
||||
assertEquals("addsystemflag", action.optString("type"))
|
||||
assertTrue(action.optString("flag").contains("Seen"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withEnable_preserves_unknown_fields() {
|
||||
val raw = JSONObject(
|
||||
"""{"name":"web","enable":true,"operator":"anyof","priority":20,
|
||||
"tests":[{"field":"subject","operator":"is","values":["x"]},
|
||||
{"field":"from","operator":"contains","values":["y"]}],
|
||||
"actions":[{"type":"stop"}],"customField":"keepme"}""",
|
||||
)
|
||||
val rule = MailFilterRule(raw)
|
||||
assertFalse(rule.isSimple)
|
||||
val disabled = rule.withEnable(false)
|
||||
assertFalse(disabled.enable)
|
||||
assertEquals("keepme", disabled.raw.optString("customField"))
|
||||
assertEquals(2, disabled.tests.size)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.f7cloud.talk.activities.CallActivity
|
||||
import ru.f7cloud.talk.services.CallForegroundService
|
||||
import ru.f7cloud.talk.utils.bundle.BundleKeys
|
||||
@@ -22,6 +26,9 @@ object TalkNativeCallLauncher {
|
||||
private const val TAG = "TalkNativeCallLauncher"
|
||||
private const val WEBVIEW_CALL_ACTIVITY = "ru.forbion.f7cloud.feature.talk.TalkCallActivity"
|
||||
|
||||
// Main-scope для запуска звонка после suspend-подготовки vendor-юзера (IO)
|
||||
private val launchScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
fun launchIncomingCall(
|
||||
context: Context,
|
||||
session: AuthSession,
|
||||
@@ -83,26 +90,30 @@ object TalkNativeCallLauncher {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
|
||||
showError(context, "Не удалось подготовить Talk для звонка")
|
||||
return
|
||||
}
|
||||
val token = callContext.roomToken.trim()
|
||||
if (token.isBlank()) {
|
||||
showError(context, "Некорректная комната звонка")
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
startCallActivity(
|
||||
context,
|
||||
buildCallIntent(context, session, callContext, incomingFromNotification, suppressIncomingRingtone),
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Log.e(TAG, "Native CallActivity launch failed", error)
|
||||
if (TalkNativeConfig.allowWebViewCallFallback) {
|
||||
launchWebViewFallback(context, session, token, incomingFromNotification)
|
||||
} else {
|
||||
showError(context, "Не удалось начать звонок")
|
||||
// Подготовка vendor-юзера — suspend (IO): раньше runBlocking держал main
|
||||
// на пути звонка (ANR-риск на входящем звонке, аудит C4). Запуск activity — на main.
|
||||
launchScope.launch {
|
||||
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
|
||||
showError(context, "Не удалось подготовить Talk для звонка")
|
||||
return@launch
|
||||
}
|
||||
runCatching {
|
||||
startCallActivity(
|
||||
context,
|
||||
buildCallIntent(context, session, callContext, incomingFromNotification, suppressIncomingRingtone),
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Log.e(TAG, "Native CallActivity launch failed", error)
|
||||
if (TalkNativeConfig.allowWebViewCallFallback) {
|
||||
launchWebViewFallback(context, session, token, incomingFromNotification)
|
||||
} else {
|
||||
showError(context, "Не удалось начать звонок")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ package ru.forbion.f7cloud.feature.talknative
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
|
||||
@@ -10,15 +14,20 @@ import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
*/
|
||||
object TalkVendorBootstrap {
|
||||
private const val TAG = "TalkVendorBootstrap"
|
||||
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Прогрев vendor-юзера на старте — в фоне (раньше блокировал main в Application.onCreate). */
|
||||
fun onApplicationCreate(context: Context) {
|
||||
if (!TalkNativeConfig.bootstrapVendorRuntime) return
|
||||
val session = AuthStore(context).load() ?: return
|
||||
runCatching { TalkVendorUserSync.ensureUser(context, session) }
|
||||
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
|
||||
val appContext = context.applicationContext
|
||||
warmupScope.launch {
|
||||
val session = AuthStore(appContext).load() ?: return@launch
|
||||
runCatching { TalkVendorUserSync.ensureUser(appContext, session) }
|
||||
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureSessionReady(context: Context, session: AuthSession): Boolean {
|
||||
suspend fun ensureSessionReady(context: Context, session: AuthSession): Boolean {
|
||||
if (!TalkNativeConfig.bootstrapVendorRuntime) return true
|
||||
return runCatching { TalkVendorUserSync.ensureUser(context, session) }
|
||||
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
|
||||
|
||||
@@ -5,22 +5,25 @@ import android.util.Log
|
||||
import ru.f7cloud.talk.application.F7cloudTalkApplication
|
||||
import ru.f7cloud.talk.f7cloud.F7TalkUserSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
|
||||
/**
|
||||
* Ensures talk-android [ru.f7cloud.talk.data.user.model.User] exists for F7cloud sessions.
|
||||
* Required before native [ru.f7cloud.talk.activities.CallActivity] can run.
|
||||
*
|
||||
* suspend (IO) — раньше был runBlocking на main (старт приложения и путь звонка),
|
||||
* что давало ANR-риск из аудита (C4/п.7).
|
||||
*/
|
||||
object TalkVendorUserSync {
|
||||
private const val TAG = "TalkVendorUserSync"
|
||||
|
||||
fun ensureUser(context: Context, session: AuthSession): Boolean {
|
||||
suspend fun ensureUser(context: Context, session: AuthSession): Boolean {
|
||||
if (context.applicationContext !is F7cloudTalkApplication) {
|
||||
Log.w(TAG, "Application is not F7cloudTalkApplication, skip sync")
|
||||
return false
|
||||
}
|
||||
return runBlocking(Dispatchers.IO) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
F7TalkUserSync.get().ensureUser(
|
||||
serverUrl = session.serverUrl,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.forbion.f7cloud.feature.talk
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.Credentials
|
||||
@@ -87,12 +89,13 @@ fun TalkChatBackground(
|
||||
.fillMaxSize()
|
||||
.background(F7Colors.ChatBackground),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = TalkAssets.spreed(session, "chat-pattern.svg"),
|
||||
// Локальная подложка чата из дизайна (Подложка2): светло-зелёный градиент + водяной
|
||||
// паттерн иконок. Была с сервера — теперь офлайн и точно из макета.
|
||||
Image(
|
||||
painter = painterResource(R.drawable.chat_background),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alpha = 0.35f,
|
||||
)
|
||||
content()
|
||||
}
|
||||
@@ -908,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()) {
|
||||
@@ -963,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(
|
||||
@@ -1001,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(
|
||||
@@ -1589,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"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -304,7 +306,7 @@ fun TalkScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.rooms.isEmpty(),
|
||||
error = state.error,
|
||||
onRefresh = { vm.load(session) },
|
||||
onErrorRetry = { vm.load(session) },
|
||||
) {
|
||||
RoomListContent(
|
||||
session = session,
|
||||
@@ -319,11 +321,14 @@ fun TalkScreen(
|
||||
onCreateRoom = vm::openCreateRoom,
|
||||
onToggleFavorite = { vm.toggleFavorite(session, it.token) },
|
||||
onMarkUnread = { vm.markRoomUnread(session, it.token) },
|
||||
refreshing = state.loading && state.rooms.isNotEmpty(),
|
||||
onRefresh = { vm.load(session) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomListContent(
|
||||
session: AuthSession,
|
||||
@@ -338,6 +343,8 @@ private fun RoomListContent(
|
||||
onCreateRoom: () -> Unit,
|
||||
onToggleFavorite: (TalkRoom) -> Unit,
|
||||
onMarkUnread: (TalkRoom) -> Unit,
|
||||
refreshing: Boolean = false,
|
||||
onRefresh: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -352,6 +359,13 @@ private fun RoomListContent(
|
||||
onCreateClick = onCreateRoom,
|
||||
)
|
||||
TalkFilterChips(selected = roomFilter, onClear = onClearFilter)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
if (rooms.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -392,6 +406,7 @@ private fun RoomListContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 48 KiB |
@@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -143,6 +145,7 @@ fun TasksScreen(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TasksMainContent(
|
||||
session: AuthSession,
|
||||
@@ -190,6 +193,13 @@ private fun TasksMainContent(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.loading,
|
||||
onRefresh = onReload,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -229,6 +239,7 @@ private fun TasksMainContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,3 +60,4 @@ multidex = { group = "androidx.multidex", name = "multidex", version.ref = "mult
|
||||
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
|
||||
documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.0.1" }
|
||||
|
||||