1 Commits

Author SHA1 Message Date
b-dev-mobile 321f81bffe Этап 0 п.3 (доп): биометрическая привязка app password поверх шифрования
Auth-bound RSA-пара `f7_auth_master_bio` в AndroidKeystore
(setUserAuthenticationRequired + setInvalidatedByBiometricEnrollment,
per-use CryptoObject, префикс v2b:): ПУБЛИЧНЫЙ ключ шифрует без авторизации
(в т.ч. в фоне — миграция/сохранение после входа), ПРИВАТНЫЙ расшифровывает
ТОЛЬКО через BiometricPrompt+CryptoObject. Симметричный AES не подошёл бы:
там auth требуется и на шифрование.

Разрешение конфликта с фоновыми push/звонками: пароль расшифровывается ОДИН
раз при разблокировке (AppLockGate) и живёт в памяти процесса
(AppPasswordHolder, не на диске). AuthStore.load() в фоне возвращает null,
пока «холодно» → существующие `?: return` в push/звонках деградируют сами;
экраны Accept (CallIncomingActivity, TalkPushBridge) открывают приложение
для разблокировки вместо тихого отказа. AppScaffold через loadMetadata()
показывает экран блокировки, а не логин, и перечитывает сессию после разблокировки.

ВЫКЛ по умолчанию (AppLockStore.isPasswordBindingEnabled, только при
биометрии-без-PIN — единственный путь разблокировки прогревает holder).
UI-тумблер и включение по умолчанию — ПОСЛЕ подтверждения владельцем поведения
«после ребута до первой разблокировки звонки/push не работают» (как в Signal).

v0.5.115 (123). Проверено сборкой assembleRelease на инфре B: APK 54.6 МБ,
подпись v2+v3 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:49:45 +00:00
126 changed files with 1772 additions and 2884 deletions
-64
View File
@@ -1,64 +0,0 @@
name: CI
# Активируется, когда на инфре B зарегистрирован self-hosted act_runner с меткой `f7-android`
# (JDK 21 + Android SDK 36 — как на билд-машине). Пока раннера нет — джобы ждут исполнителя.
# Регистрация раннера — инфра-шаг ячейки B (см. docs/CI.md).
on:
push:
branches: [main]
tags: ['v*']
pull_request:
env:
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
ANDROID_HOME: /opt/android-sdk
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
jobs:
verify:
# lint + unit-тесты + debug-сборка на каждый push в main и на PR
runs-on: [self-hosted, f7-android]
if: github.ref_type != 'tag'
steps:
- uses: actions/checkout@v4
- name: Статанализ (detekt + ktlint-форматирование)
run: ./gradlew detekt --no-daemon
- name: Unit-тесты
run: ./gradlew testDebugUnitTest --no-daemon
- name: Android Lint (release)
run: ./gradlew :app:lintRelease --no-daemon
- name: Debug-сборка (артефакт для QA)
run: ./gradlew :app:assembleDebug --no-daemon
- name: Публикация debug-APK
uses: actions/upload-artifact@v3
with:
name: f7cloud-mobile-debug
path: app/build/outputs/apk/debug/*.apk
release:
# по тегу v*: подписанный release + архив исходников (GPL corresponding source) в Gitea Release
runs-on: [self-hosted, f7-android]
if: github.ref_type == 'tag'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Развернуть keystore из secret
run: |
echo "${{ secrets.F7_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/release.jks"
- name: Release-сборка (подписанная)
env:
F7_KEYSTORE_FILE: ${{ runner.temp }}/release.jks
F7_KEYSTORE_PASSWORD: ${{ secrets.F7_KEYSTORE_PASSWORD }}
F7_KEY_ALIAS: ${{ secrets.F7_KEY_ALIAS }}
F7_KEY_PASSWORD: ${{ secrets.F7_KEY_PASSWORD }}
run: ./gradlew :app:assembleRelease --no-daemon
- name: Архив исходников (GPL corresponding source)
run: bash scripts/package-source.sh "$RUNNER_TEMP/dist"
- name: Публикация Gitea Release (APK + исходники)
uses: akkuman/gitea-release-action@v1
with:
files: |
app/build/outputs/apk/release/*.apk
${{ runner.temp }}/dist/*-source.tar.gz
+1 -50
View File
@@ -2,56 +2,7 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 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-07 | v0.5.115 (123) | Этап 0 п.3 (доп): биометрическая привязка app password поверх шифрования в покое. Auth-bound RSA-пара `f7_auth_master_bio` в AndroidKeystore (`setUserAuthenticationRequired`, `setInvalidatedByBiometricEnrollment`, per-use CryptoObject, префикс `v2b:`): ПУБЛИЧНЫЙ ключ шифрует без авторизации (в т.ч. в фоне — миграция/сохранение после входа), ПРИВАТНЫЙ расшифровывает ТОЛЬКО через BiometricPrompt+CryptoObject. Расшифровка ОДИН раз при разблокировке (AppLockGate) → `AppPasswordHolder` в памяти процесса (не на диске). Разрешает конфликт с фоновыми push/звонками: `AuthStore.load()` в фоне возвращает null «холодным» → существующие `?: return` деградируют сами; экраны Accept открывают приложение для разблокировки вместо тихого отказа. ВЫКЛ по умолчанию (`AppLockStore.isPasswordBindingEnabled`, только при биометрии-без-PIN); UI-тумблер и включение по умолчанию — ПОСЛЕ подтверждения владельцем поведения «после ребута до разблокировки звонки/push не работают» (как в Signal). Проверено сборкой assembleRelease на инфре B | контракты не менялись | средний: затрагивает хранилище пароля и путь разблокировки; по умолчанию поведение прежнее
- 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-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 2): локальные иконки типов файлов. 13 SVG из дизайна (PDF/Word/Spreadsheet/Presentation/Image/Audio/Video/Archive/Code/Text/Certificate/Eml/file) в assets/filetypes/, FileIcons переключён на них (file:///android_asset, coil-svg). Раньше иконки типов тянулись с сервера → офлайн ломался (аудит) + не гарантированно совпадали с дизайном. Папка пока с сервера (иконки папки в наборе нет — TODO из Figma). Дизайн-макеты (109 PNG) — на инфре B /home/foradmin/figma/, в git не бандлю (вес) | контракты не менялись | низкий: файловые иконки теперь офлайн и точно из дизайна
- 2026-07-08 | v0.5.114 (122) | Трек дизайна (старт): снятие дизайн-токенов из Figma роли design (файл ocVUCYCrfFUoqYYvlcLPBF, доступ по API-токену вне git). Цвета сверены — совпадают с F7Colors; типографика — Raleway 14/18/20/24; заведены F7Spacing (4dp-сетка) + F7Radius (аудит: не было spacing-токенов, ~1087 инлайновых .dp). docs/DESIGN-SOURCE.md — карта Figma + процесс попиксельной вёрстки. Источник — Figma, боевой forbion НЕ трогаю. Координация — mail/044 | дизайн-система (аддитивно, пока не используется) | нет: новые токены, поведение не меняется
- 2026-07-07 | v0.5.114 (122) | Этап 1 (version catalog): заведён gradle/libs.versions.toml, 15 module build.gradle мигрированы на libs.* (скриптом). Устранён дрейф версий: coroutines 1.8.1/1.10.1→1.10.1, coil 2.6.0/2.7.0→2.7.0, lifecycle 2.8.1/2.8.7→2.8.7, activity-compose 1.9.0/1.10.1→1.10.1, core-ktx 1.13.1/1.15.0→1.15.0. Firebase в core/push переведён на bom (был explicit 24.0.1). Compose BOM в каталоге вместо дубля в каждом модуле. Проверено сборкой | контракты не менялись; версии унифицированы вверх (minor-бампы) | низкий: сборочная гигиена, унификация версий
- 2026-07-07 | v0.5.114 (122) | Этап 1 (статанализ): detekt 1.23.8 + formatting-ruleset (ktlint) на все наши модули (vendor исключён), совместим с Kotlin 2.3. config/detekt/detekt.yml + per-module detekt-baseline.xml (~403 текущих замечаний зафиксированы → зелёный, ловит только новые). Добавлен в CI verify-джобу. Нормальные сборки (assembleDebug/Release) не затрагивает (detekt только в check/CI) | процесс качества | нет: тулинг, кода приложения не трогает
- 2026-07-07 | v0.5.114 (122) | Этап 1 (единый OkHttpClient, ч.2 — 401 централизован): добавлен UnauthorizedInterceptor (401→UnauthorizedException) в NetworkFactory (флаг throwOnUnauthorized, по умолч. true). Удалены 75 ручных проверок `if(code==401) throw` в 16 репозиториях. Login-верификация (AuthVerifier) исключена (throwOnUnauthorized=false: 401=«неверный пароль», не session-expired). Побочно: теперь 401 ловится единообразно ВЕЗДЕ, включая пути, что раньше проверку забывали. +3 теста (MockWebServer: 401 бросает, 200/403 проходят) — всего 18. Проверено сборкой | контракты не менялись; поведение 401 сохранено (interceptor бросает то же исключение) | средний: затрагивает обработку 401 во всех репозиториях; login-путь исключён явно, покрыто тестами
- 2026-07-07 | v0.5.114 (122) | Этап 1 (единый OkHttpClient, ч.1): NetworkFactory переписан — раньше создавал НОВЫЙ OkHttpClient на КАЖДЫЙ запрос (~32 места) → TLS-handshake + новый пул/диспатчер на запрос + утечка ExecutorService. Теперь один базовый клиент (общий ConnectionPool/Dispatcher + диск-кэш 20МБ), авторизованные варианты через base.newBuilder() (шарят пул/кэш), кэшируются по кредам+таймаутам. Публичный API НЕ менялся — 32 вызова не тронуты. Init диск-кэша — F7MobileApp.onCreate. +3 unit-теста (переиспользование клиента, общий пул). 401→interceptor НЕ делал (меняет семантику исключений — отдельный шаг) | контракты не менялись | средний: затрагивает ВСЕ сетевые вызовы (переиспользование соединений); поведение запросов то же, проверено 15 тестами + assembleRelease
- 2026-07-07 | v0.5.114 (122) | Этап 1 (CI + GPL-публикация): добавлен Gitea Actions workflow .gitea/workflows/ci.yml (push/PR → тесты+lint+assembleDebug; тег v* → подписанный release + архив исходников в Gitea Release) — Gitea 1.26.1 поддерживает Actions. Скрипт scripts/package-source.sh (GPL corresponding source §6: архив всего дерева вкл. vendor GPL/LICENSE/NOTICE — проверен, 6.7 МБ) закрывает п.4c, работает и вручную. docs/CI.md. НЕ активно до регистрации self-hosted раннера на инфре B (нужен Gitea-админ для токена — инфра-шаг) | процесс сборки/релиза | нет: конфиг CI, кода приложения не трогает
- 2026-07-07 | v0.5.114 (122) | Этап 1 (баги данных, test-first): заведена тестовая инфраструктура (core:network testImplementation junit) + первые 12 unit-тестов. Починено: (1) ICS-парсер дат — суффикс `Z` теперь UTC (был локальным), учитывается TZID, floating→локаль; (2) parseProps сохраняет параметры свойств — несколько ATTENDEE больше НЕ схлопываются, CN/PARTSTAT/ROLE читаются (была порча участников при редактировании→PUT на сервер); (3) unescape single-pass (последовательные replace ломались на `\\n`); (4) CalDavClient.parseIcsInstant делегирует в CalendarIcs — устранён рассинхрон календарь↔задачи; (5) QR-логин: маркер-парсер вместо split('&') — пароль с `&`/`:` больше не теряется; (6) курсор пагинации почты Int→Long (обрезка Unix-времени, ломалось ~2038). Проверено: 12 тестов зелёные + assembleRelease | контракты не менялись; исправлена запись битых ATTENDEE на сервер | низкий: чинит порчу данных, покрыто тестами
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.5 (gradle-гигиена): org.gradle.parallel=true (ускорение сборки 20 модулей); удалены мёртвые модули core:data, core:ui (пустые заготовки) и feature:widgets (не был в settings.gradle, не компилировался) + чистка settings.gradle. nonTransitiveRClass и убирание jetifier — ПРОБОВАЛ, откатил: vendor-форк talk-android ломается (R транзитивно + legacy android.support.* в его depS); причины в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. **Этап 0 завершён** (кроме п.4c — публикация исходников, к CI этапа 1) | контракты не менялись | низкий: удалён неиспользуемый код, ускорена сборка
- 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция) - 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет - 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.3 (безопасность-минимум): (1) trust-all TLS отключён в release — гейт `if(!BuildConfig.DEBUG)` в UnsafeSsl + обе WebView-точки (OfficeWebViewClient, TalkCallActivity), чекбокс «доверять сертификату» скрыт в release (buildConfig включён в core:network и feature:talk); (2) allowBackup=false (переопределяет vendor true, подтверждено в собранном манифесте); (3) app password шифруется в покое ключом AndroidKeystore AES-256-GCM (KeystoreCrypto) + одноразовая миграция legacy-plaintext при load; (4) ревокация app password при logout (AppPasswordRevoker: DELETE /ocs/v2.php/core/apppassword, best-effort). Биометрическая привязка ключа — отдельная задача (конфликтует с фоновыми push/звонками, нужен кэш в памяти). Проверено сборкой: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false OK | НОВЫЙ вызов OCS: DELETE core/apppassword при logout (стандартный NC endpoint) | средний: миграция хранилища и logout затрагивают все сессии — проверить на устройстве вход/выход/перезапуск - 2026-07-07 | v0.5.114 (122) | Этап 0 п.3 (безопасность-минимум): (1) trust-all TLS отключён в release — гейт `if(!BuildConfig.DEBUG)` в UnsafeSsl + обе WebView-точки (OfficeWebViewClient, TalkCallActivity), чекбокс «доверять сертификату» скрыт в release (buildConfig включён в core:network и feature:talk); (2) allowBackup=false (переопределяет vendor true, подтверждено в собранном манифесте); (3) app password шифруется в покое ключом AndroidKeystore AES-256-GCM (KeystoreCrypto) + одноразовая миграция legacy-plaintext при load; (4) ревокация app password при logout (AppPasswordRevoker: DELETE /ocs/v2.php/core/apppassword, best-effort). Биометрическая привязка ключа — отдельная задача (конфликтует с фоновыми push/звонками, нужен кэш в памяти). Проверено сборкой: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false OK | НОВЫЙ вызов OCS: DELETE core/apppassword при logout (стандартный NC endpoint) | средний: миграция хранилища и logout затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
+10 -1
View File
@@ -62,10 +62,19 @@ legacy WebView-оболочки `android-webview`.
## Секреты (где, НЕ значения) ## Секреты (где, НЕ значения)
- В git секретов нет. Учётные данные пользователя — только на устройстве - В git секретов нет. Учётные данные пользователя — только на устройстве
(session storage `core/auth`). (session storage `core/auth`).
- App password шифруется в покое ключом AndroidKeystore AES-256-GCM (`KeystoreCrypto`,
ключ `f7_auth_master`, префикс `v1:`). Опционально — **биометрическая привязка**
(`AppLockStore.isPasswordBindingEnabled`, ВЫКЛ по умолчанию): пароль шифруется
auth-bound RSA-парой `f7_auth_master_bio` (`setUserAuthenticationRequired`, префикс
`v2b:`), расшифровывается ТОЛЬКО через BiometricPrompt+CryptoObject один раз при
разблокировке и живёт в памяти процесса (`AppPasswordHolder`, не на диске). Фоновые
сценарии (push/входящие Talk-звонки) до первой разблокировки после ребута деградируют
корректно («Разблокируйте приложение…»). Включение по умолчанию/UI-тумблер — ПОСЛЕ
подтверждения владельцем допустимости этого поведения (как в Signal).
- `google-services.json` (Firebase) — вне git; путь сборки документируется в - `google-services.json` (Firebase) — вне git; путь сборки документируется в
PROJECT-STATUS.md. PROJECT-STATUS.md.
- Release-keystore для подписи — вне git (пока не настроен, сборки debug). - Release-keystore для подписи — вне git (пока не настроен, сборки debug).
## Версия / changelog ## Версия / changelog
- Текущее: v0.5.113 (code 121) — модуль «Задачи». История — CHANGELOG.md - Текущее: v0.5.115 (code 123) — биопривязка app password (Этап 0 п.3 доп). История — CHANGELOG.md
и git-история `app/build.gradle`. и git-история `app/build.gradle`.
+32 -35
View File
@@ -39,14 +39,16 @@ android {
applicationId 'ru.forbion.f7cloud.mobile' applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26 minSdk 26
targetSdk 36 targetSdk 36
versionCode 131 versionCode 123
versionName '0.5.123' versionName '0.5.115'
missingDimensionStrategy 'default', 'f7' missingDimensionStrategy 'default', 'f7'
multiDexEnabled true multiDexEnabled true
// ABI задаём ПОБИЛДТИПОВО (ниже): release — ARM-only (компактно), debug — все ABI // Универсальный APK для реальных устройств: только ARM (arm64 + armeabi-v7a),
// (включая x86/x86_64), чтобы debug-сборка нативно шла на эмуляторах (BlueStacks и т.п.) // без эмуляторных x86/x86_64 — режет ~половину native-веса (WebRTC .so).
// и на физических устройствах. ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a'
}
// Оставляем только нужные локали (у vendor-форка ~48 языков) — минус несколько МБ. // Оставляем только нужные локали (у vendor-форка ~48 языков) — минус несколько МБ.
// resourceConfigurations — не-deprecated бэкинг resConfigs. // resourceConfigurations — не-deprecated бэкинг resConfigs.
resourceConfigurations += ['ru', 'en'] resourceConfigurations += ['ru', 'en']
@@ -60,16 +62,11 @@ android {
} }
buildTypes { buildTypes {
debug { debug {}
// Все ABI — чтобы debug нативно работал на x86-эмуляторах (BlueStacks) И на ARM-девайсах.
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64' }
}
release { release {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// прод — только ARM (реальные устройства), компактно
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' }
// боевой keystore — если задан env/-P; иначе debug-ключ (не для распространения) // боевой keystore — если задан env/-P; иначе debug-ключ (не для распространения)
signingConfig f7HasReleaseSigning ? signingConfigs.release : signingConfigs.debug signingConfig f7HasReleaseSigning ? signingConfigs.release : signingConfigs.debug
} }
@@ -111,30 +108,30 @@ dependencies {
implementation project(':feature:mail') implementation project(':feature:mail')
implementation project(':feature:f7support') implementation project(':feature:f7support')
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
androidTestImplementation composeBom androidTestImplementation composeBom
implementation libs.activity.compose implementation 'androidx.activity:activity-compose:1.10.1'
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.ui.tooling.preview implementation 'androidx.compose.ui:ui-tooling-preview'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.navigation.compose implementation 'androidx.navigation:navigation-compose:2.8.9'
implementation libs.lifecycle.viewmodel.compose implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
implementation libs.lifecycle.runtime.compose implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
implementation libs.lifecycle.process implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
implementation libs.coil.compose implementation 'io.coil-kt:coil-compose:2.7.0'
implementation libs.coil.svg implementation 'io.coil-kt:coil-svg:2.7.0'
implementation platform(libs.firebase.bom) implementation platform('com.google.firebase:firebase-bom:33.7.0')
implementation libs.firebase.messaging implementation 'com.google.firebase:firebase-messaging'
implementation libs.coroutines.play.services implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0'
implementation libs.multidex implementation 'androidx.multidex:multidex:2.0.1'
implementation libs.biometric implementation 'androidx.biometric:biometric:1.1.0'
implementation libs.fragment.ktx implementation 'androidx.fragment:fragment-ktx:1.8.6'
implementation libs.camera.camera2 implementation 'androidx.camera:camera-camera2:1.5.2'
implementation libs.camera.lifecycle implementation 'androidx.camera:camera-lifecycle:1.5.2'
implementation libs.camera.view implementation 'androidx.camera:camera-view:1.5.2'
implementation libs.zxing implementation 'com.google.zxing:core:3.3.0'
implementation libs.compose.material.icons.extended implementation 'androidx.compose.material:material-icons-extended'
implementation libs.guava implementation 'com.google.guava:guava:33.3.1-android'
debugImplementation libs.compose.ui.tooling debugImplementation 'androidx.compose.ui:ui-tooling'
} }
File diff suppressed because one or more lines are too long
@@ -86,7 +86,25 @@ class CallIncomingActivity : ComponentActivity() {
private fun acceptCall(launch: IncomingCallLaunch) { private fun acceptCall(launch: IncomingCallLaunch) {
F7IncomingCallRinger.stop() F7IncomingCallRinger.stop()
val session = AuthStore(this).load() ?: return val authStore = AuthStore(this)
val session = authStore.load()
if (session == null) {
// При биопривязке пароль расшифрован только после разблокировки приложения. Если
// «холодно» (после ребута/повторной блокировки) — открываем приложение, чтобы человек
// прошёл биометрию, а не молча роняем звонок.
if (authStore.isPasswordLocked()) {
android.widget.Toast.makeText(
this,
"Разблокируйте приложение, чтобы принять звонок",
android.widget.Toast.LENGTH_LONG,
).show()
packageManager.getLaunchIntentForPackage(packageName)?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(it)
}
}
return
}
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken) F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
TalkNativeCallLauncher.launchIncomingCall( TalkNativeCallLauncher.launchIncomingCall(
this, this,
@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthStore import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.push.F7NotificationChannels import ru.forbion.f7cloud.core.push.F7NotificationChannels
import ru.forbion.f7cloud.core.push.F7PushRegistrar import ru.forbion.f7cloud.core.push.F7PushRegistrar
import ru.forbion.f7cloud.feature.talknative.TalkVendorBootstrap import ru.forbion.f7cloud.feature.talknative.TalkVendorBootstrap
@@ -38,7 +37,6 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
} }
}) })
F7NotificationChannels.ensureAll(this) F7NotificationChannels.ensureAll(this)
NetworkFactory.init(this) // общий HTTP-клиент + диск-кэш (переиспользование соединений)
TalkVendorBootstrap.onApplicationCreate(this) TalkVendorBootstrap.onApplicationCreate(this)
val auth = AuthStore(this).load() ?: return val auth = AuthStore(this).load() ?: return
try { try {
@@ -1,5 +1,6 @@
package ru.forbion.f7cloud.mobile.ui package ru.forbion.f7cloud.mobile.ui
import android.util.Log
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -51,9 +52,11 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import ru.forbion.f7cloud.core.auth.AppLockStore import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.auth.AppPasswordHolder
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.auth.KeystoreCrypto
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton 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.F7TextButton
import ru.forbion.f7cloud.mobile.R import ru.forbion.f7cloud.mobile.R
@@ -64,6 +67,7 @@ private enum class AppLockSetupStage {
} }
private const val PIN_LENGTH = 4 private const val PIN_LENGTH = 4
private const val TAG = "AppLockGate"
private fun biometricAuthenticators(): Int = private fun biometricAuthenticators(): Int =
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_STRONG or
@@ -77,6 +81,7 @@ private fun canUseBiometric(context: android.content.Context): Boolean =
fun AppLockGate( fun AppLockGate(
lockStore: AppLockStore, lockStore: AppLockStore,
unlockNonce: Int = 0, unlockNonce: Int = 0,
onUnlocked: () -> Unit = {},
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
if (!lockStore.isEnabled()) { if (!lockStore.isEnabled()) {
@@ -86,11 +91,18 @@ fun AppLockGate(
var unlocked by remember { mutableStateOf(false) } var unlocked by remember { mutableStateOf(false) }
var lockSession by remember { mutableIntStateOf(0) } var lockSession by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) { // При повторной блокировкe забываем расшифрованный пароль из памяти: заблокированное
if (lockStore.consumeColdStart()) { // приложение не должно держать секрет резидентно (фоновые сценарии деградируют до разблокировки).
fun relock() {
AppPasswordHolder.clear()
unlocked = false unlocked = false
lockSession++ lockSession++
} }
LaunchedEffect(Unit) {
if (lockStore.consumeColdStart()) {
relock()
}
} }
val processLifecycle = ProcessLifecycleOwner.get() val processLifecycle = ProcessLifecycleOwner.get()
@@ -100,14 +112,8 @@ fun AppLockGate(
Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded() Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded()
Lifecycle.Event.ON_START -> { Lifecycle.Event.ON_START -> {
when { when {
lockStore.consumeColdStart() -> { lockStore.consumeColdStart() -> relock()
unlocked = false lockStore.shouldRequireUnlock() -> relock()
lockSession++
}
lockStore.shouldRequireUnlock() -> {
unlocked = false
lockSession++
}
} }
} }
else -> Unit else -> Unit
@@ -134,6 +140,7 @@ fun AppLockGate(
onUnlocked = { onUnlocked = {
lockStore.clearBackgroundMarker() lockStore.clearBackgroundMarker()
unlocked = true unlocked = true
onUnlocked()
}, },
) )
} }
@@ -146,7 +153,6 @@ fun AppLockSetupDialog(
onLockConfigured: () -> Unit = {}, onLockConfigured: () -> Unit = {},
) { ) {
if (!visible) return if (!visible) return
F7SecureScreen() // создание/подтверждение PIN — не в скриншотах/recents
val context = LocalContext.current val context = LocalContext.current
val activity = context.findFragmentActivity() val activity = context.findFragmentActivity()
@@ -536,7 +542,6 @@ private fun AppLockUnlockScreen(
lockSession: Int, lockSession: Int,
onUnlocked: () -> Unit, onUnlocked: () -> Unit,
) { ) {
F7SecureScreen() // экран разблокировки (PIN) — не в скриншотах/recents
val context = LocalContext.current val context = LocalContext.current
val activity = context.findFragmentActivity() val activity = context.findFragmentActivity()
var pin by remember { mutableStateOf("") } var pin by remember { mutableStateOf("") }
@@ -545,6 +550,12 @@ private fun AppLockUnlockScreen(
val biometricOnly = lockStore.isBiometricOnly() val biometricOnly = lockStore.isBiometricOnly()
val biometricAvailable = remember { canUseBiometric(context) } val biometricAvailable = remember { canUseBiometric(context) }
// Биометрическая привязка пароля: при разблокировке расшифровываем app password через
// CryptoObject и кладём в память (AppPasswordHolder), чтобы фоновые сценарии его читали.
val authStore = remember { AuthStore(context) }
val bindingEnabled = lockStore.isPasswordBindingEnabled()
val boundCipherText = remember(lockSession) { authStore.boundPasswordCiphertext() }
fun verifyPinInput() { fun verifyPinInput() {
if (lockStore.verifyPin(pin)) { if (lockStore.verifyPin(pin)) {
onUnlocked() onUnlocked()
@@ -575,12 +586,39 @@ private fun AppLockUnlockScreen(
error = "Биометрия недоступна" error = "Биометрия недоступна"
return return
} }
// Режим привязки: готовим Cipher для расшифровки пароля. Если ключ инвалидирован (сменилась
// биометрия устройства) — привязку сбрасываем, чтобы не запереть пользователя навсегда;
// приложение попросит войти заново (пароль восстановить нельзя).
val useCrypto = bindingEnabled && boundCipherText != null
val decryptCipher = if (useCrypto) {
runCatching { KeystoreCrypto.initBoundDecryptCipher(boundCipherText) }.getOrElse {
Log.w(TAG, "Bound key invalidated, resetting binding", it)
lockStore.setPasswordBinding(false)
authStore.clear()
onUnlocked()
return
}
} else {
null
}
val executor = ContextCompat.getMainExecutor(context) val executor = ContextCompat.getMainExecutor(context)
val prompt = BiometricPrompt( val prompt = BiometricPrompt(
host, host,
executor, executor,
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cipher = result.cryptoObject?.cipher
if (useCrypto && cipher != null) {
val ok = runCatching {
AppPasswordHolder.set(
KeystoreCrypto.finishBoundDecrypt(cipher, boundCipherText),
)
}.isSuccess
if (!ok) {
error = "Не удалось расшифровать пароль, войдите заново"
return
}
}
onUnlocked() onUnlocked()
} }
@@ -597,14 +635,23 @@ private fun AppLockUnlockScreen(
} }
}, },
) )
prompt.authenticate( // CryptoObject требует СИЛЬНОЙ биометрии (Class 3); без привязки допускаем и weak.
BiometricPrompt.PromptInfo.Builder() val authenticators = if (useCrypto) {
BiometricManager.Authenticators.BIOMETRIC_STRONG
} else {
biometricAuthenticators()
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Разблокировка F7cloud") .setTitle("Разблокировка F7cloud")
.setSubtitle("Прикоснитесь к сканеру отпечатка") .setSubtitle("Прикоснитесь к сканеру отпечатка")
.setAllowedAuthenticators(biometricAuthenticators()) .setAllowedAuthenticators(authenticators)
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN") .setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
.build(), .build()
) if (decryptCipher != null) {
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(decryptCipher))
} else {
prompt.authenticate(promptInfo)
}
} }
LaunchedEffect(lockSession, lockStore.useBiometric(), activity) { LaunchedEffect(lockSession, lockStore.useBiometric(), activity) {
@@ -19,6 +19,7 @@ object AppMenuRepository {
val request = Request.Builder().url(url).applyOcsJson().get().build() val request = Request.Builder().url(url).applyOcsJson().get().build()
return runCatching { return runCatching {
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
return emptyList() return emptyList()
} }
@@ -1,8 +1,5 @@
package ru.forbion.f7cloud.mobile.ui package ru.forbion.f7cloud.mobile.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
enum class AppTab( enum class AppTab(
@@ -32,14 +29,16 @@ private data class AppMenuEntry(
val webPath: String? = null, val webPath: String? = null,
) )
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
private val coreAppMenuEntries = listOf( private val coreAppMenuEntries = listOf(
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"), AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"), AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"), AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"), AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"), AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
) )
data class AppMenuExternalSite( data class AppMenuExternalSite(
@@ -72,22 +71,7 @@ fun appMenuItems(
externalUrl = site.openUrl, externalUrl = site.openUrl,
) )
} }
// Нативные пункты по дизайну — действия обрабатываются в AppScaffold по label return core + external
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? { fun appTabFromMenuIndex(index: Int): AppTab? {
@@ -25,6 +25,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -81,7 +82,6 @@ import ru.forbion.f7cloud.core.push.F7PushEventHub
import ru.forbion.f7cloud.core.push.F7PushRegistrar import ru.forbion.f7cloud.core.push.F7PushRegistrar
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
@@ -118,7 +118,9 @@ fun AppScaffold(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val authStore = remember { AuthStore(context) } val authStore = remember { AuthStore(context) }
var session by remember { mutableStateOf(authStore.load()) } // loadMetadata (не load): при биопривязке до разблокировки пароля нет, но сессия ЕСТЬ —
// показываем экран блокировки, а не логин. Полную сессию с паролем берём после разблокировки.
var session by remember { mutableStateOf(authStore.loadMetadata()) }
var activeTab by rememberSaveable( var activeTab by rememberSaveable(
saver = Saver( saver = Saver(
save = { state -> state.value.name }, save = { state -> state.value.name },
@@ -228,7 +230,13 @@ fun AppScaffold(
onDismiss = { showAppLockSetup = false }, onDismiss = { showAppLockSetup = false },
onLockConfigured = { lockUnlockNonce++ }, onLockConfigured = { lockUnlockNonce++ },
) )
AppLockGate(lockStore = lockStore, unlockNonce = lockUnlockNonce) { AppLockGate(
lockStore = lockStore,
unlockNonce = lockUnlockNonce,
// После разблокировки (в т.ч. биометрией с CryptoObject) пароль уже в памяти —
// перечитываем полную сессию, чтобы сетевые вызовы получили app password.
onUnlocked = { authStore.load()?.let { session = it } },
) {
fun applyAppLink(target: AppLinkTarget) { fun applyAppLink(target: AppLinkTarget) {
activeTab = target.tab activeTab = target.tab
pendingTalkRoomToken = target.talkRoomToken pendingTalkRoomToken = target.talkRoomToken
@@ -483,7 +491,6 @@ fun AppScaffold(
serverUrl = currentSession.serverUrl, serverUrl = currentSession.serverUrl,
userId = userId, userId = userId,
config = bottomBarConfig, config = bottomBarConfig,
activeTabKey = activeTab.name,
menuOpen = menuOpen, menuOpen = menuOpen,
chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom, chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom,
navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) || navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) ||
@@ -500,22 +507,36 @@ fun AppScaffold(
else -> Unit else -> Unit
} }
}, },
onMailClick = { onCreateClick = {
if (activeTab != AppTab.Mail) { when (activeTab) {
pushTabHistory(activeTab) AppTab.Files -> {
activeTab = AppTab.Mail if (F7AppPermissions.missing(context).isNotEmpty()) {
onRequestRuntimePermissions { filesUploadRequest++ }
} else {
filesUploadRequest++
}
}
AppTab.Contacts -> contactsCreateRequest++
AppTab.Tasks -> tasksCreateRequest++
AppTab.Support -> supportCreateRequest++
else -> Unit
} }
}, },
onCardsClick = { onProfileClick = {
if (activeTab != AppTab.Deck) { menuOpen = false
pushTabHistory(activeTab) profileOpen = true
activeTab = AppTab.Deck
}
}, },
onConferencesClick = { onNotificationsClick = {
if (activeTab != AppTab.Talk) { menuOpen = false
pushTabHistory(activeTab) hasNotificationBadge = false
activeTab = AppTab.Talk notificationsOpen = true
},
onSettingsClick = {
when (activeTab) {
AppTab.Mail -> mailSettingsOpen = true
AppTab.Calendar -> calendarSettingsRequest++
AppTab.Files -> filesSettingsOpen = true
else -> Unit
} }
}, },
onMenuClick = { menuOpen = !menuOpen }, onMenuClick = { menuOpen = !menuOpen },
@@ -624,35 +645,12 @@ fun AppScaffold(
onItemClick = { index -> onItemClick = { index ->
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
val external = item.externalUrl val external = item.externalUrl
val label = item.label.trim() if (!external.isNullOrBlank()) {
when {
// Профиль — нативная панель вместо веб-ЛК forbion
label.equals("Личный кабинет", ignoreCase = true) -> {
menuOpen = false
profileOpen = true
}
// Нативные пункты по дизайну меню
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
}
}
!external.isNullOrBlank() -> {
runCatching { runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external))) context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
} }
menuOpen = false menuOpen = false
} } else {
else -> {
appTabFromMenuIndex(index)?.let { tab -> appTabFromMenuIndex(index)?.let { tab ->
if (tab != activeTab) { if (tab != activeTab) {
pushTabHistory(activeTab) pushTabHistory(activeTab)
@@ -661,7 +659,6 @@ fun AppScaffold(
} }
menuOpen = false menuOpen = false
} }
}
}, },
) )
ProfileSheet( ProfileSheet(
@@ -690,7 +687,6 @@ fun AppScaffold(
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2" private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
@Composable @Composable
private fun LoginScreen(onLogin: (AuthSession) -> Unit) { private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
F7SecureScreen() // ввод пароля — не в скриншотах/recents
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -891,6 +887,24 @@ private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
submitLogin() 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)) Spacer(Modifier.height(8.dp))
F7PrimaryButton( F7PrimaryButton(
text = if (loading) "" else "Войти", text = if (loading) "" else "Войти",
@@ -1,38 +1,17 @@
package ru.forbion.f7cloud.mobile.ui 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState 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.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.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -42,30 +21,22 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.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 androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Credentials import okhttp3.Credentials
import okhttp3.Request
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow 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.designsystem.formatNotificationRelativeTime
import ru.forbion.f7cloud.core.network.F7Notification 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.NotificationsRepository
import ru.forbion.f7cloud.core.network.UnauthorizedException import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
private fun themeHeaderAsset(serverUrl: String, fileName: String): String = private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName" "${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
@@ -83,178 +54,46 @@ fun ProfileSheet(
onLogout: () -> Unit, onLogout: () -> Unit,
onScanBrowserQr: () -> 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( F7FloatingPanel(
visible = visible, visible = visible,
onDismiss = onDismiss, onDismiss = onDismiss,
) { ) {
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка QR Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
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( Text(
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?", text = session.username,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyLarge,
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, color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
Text( Text(
"Открыть профиль", text = session.serverUrl,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = F7Colors.Primary, color = F7Colors.TextSecondary,
) )
} session.davUserId?.let { davId ->
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()
}
}
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( Text(
"Нативный клиент F7cloud: почта, файлы, календарь, контакты, задачи, конференции, поддержка.", text = "ID: $davId",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary, color = F7Colors.TextSecondary,
) )
} }
F7PrimaryButton(
text = "Сканировать QR браузера",
onClick = {
onDismiss()
onScanBrowserQr()
}, },
modifier = Modifier.fillMaxWidth(),
) )
} F7PrimaryButton(
text = "Выйти",
/** Отображаемое имя пользователя из OCS (fallback — пусто). */ onClick = {
private suspend fun fetchDisplayName(session: AuthSession): String = withContext(Dispatchers.IO) { onDismiss()
runCatching { onLogout()
val client = NetworkFactory.newAuthedClient( },
session.username, modifier = Modifier.fillMaxWidth(),
session.appPassword,
session.trustAllCerts,
) )
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json" F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
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 @Composable
-17
View File
@@ -8,23 +8,6 @@ plugins {
id 'org.jetbrains.kotlin.plugin.parcelize' version '2.3.0' apply false id 'org.jetbrains.kotlin.plugin.parcelize' version '2.3.0' apply false
id 'com.google.devtools.ksp' version '2.3.4' apply false id 'com.google.devtools.ksp' version '2.3.4' apply false
id 'com.google.gms.google-services' version '4.4.2' apply false id 'com.google.gms.google-services' version '4.4.2' apply false
id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
} }
ext.kotlinVersion = '2.3.0' ext.kotlinVersion = '2.3.0'
// Detekt на НАШИХ модулях (vendor-форк talk-android не линтуем — чужой код).
subprojects {
if (path != ':vendor:talk-app') {
apply plugin: 'io.gitlab.arturbosch.detekt'
detekt {
buildUponDefaultConfig = true
parallel = true
config.setFrom(rootProject.file('config/detekt/detekt.yml'))
baseline = file("$projectDir/detekt-baseline.xml")
}
dependencies {
detektPlugins 'io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8'
}
}
}
-38
View File
@@ -1,38 +0,0 @@
# Detekt — статанализ + форматирование (formatting-ruleset = обёртка ktlint).
# buildUponDefaultConfig=true в build.gradle → это дополняет дефолты detekt.
# Подход адаптации на существующий код: baseline фиксирует текущие замечания,
# сборка падает только на НОВЫХ. Постепенно расчищаем baseline.
build:
maxIssues: 0 # с baseline: только новые замечания валят
complexity:
LongMethod:
active: true
threshold: 120 # у нас есть большие composable — не хотим шум сверх меры
LongParameterList:
active: false # Compose-функции с многими колбэками — норма
TooManyFunctions:
active: false
CyclomaticComplexMethod:
threshold: 25
style:
MaxLineLength:
maxLineLength: 140
MagicNumber:
active: false # в UI-коде много dp/цветов — отдельная задача (дизайн-токены)
ReturnCount:
active: false
UnusedPrivateMember:
active: true
exceptions:
TooGenericExceptionCaught:
active: false # много catch(Throwable) в фоновых путях — осознанно
SwallowedException:
active: false
naming:
FunctionNaming:
active: false # @Composable функции с Заглавной — стандарт Compose
+1 -1
View File
@@ -21,5 +21,5 @@ android {
dependencies { dependencies {
implementation project(':core:network') implementation project(':core:network')
implementation libs.coroutines.android implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
} }
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:AuthErrors.kt$this is SSLPeerUnverifiedException || this is SSLHandshakeException || this is CertPathValidatorException || this is SSLException || msg.contains("not verified", ignoreCase = true) || msg.contains("CertificateException", ignoreCase = true) || msg.contains("Trust anchor", ignoreCase = true)</ID>
<ID>NoUnusedImports:OcsUserResolver.kt$ru.forbion.f7cloud.core.auth.OcsUserResolver.kt</ID>
</CurrentIssues>
</SmellBaseline>
@@ -66,12 +66,27 @@ class AppLockStore(context: Context) {
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false) fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
/**
* Биометрическая привязка app password (auth-bound ключ + кэш в памяти). ВЫКЛ по умолчанию.
* Разрешена только при биометрии-без-PIN: единственный путь разблокировки — биометрия, значит
* при каждой разблокировке гарантированно прогревается [AppPasswordHolder] через CryptoObject.
* Меняет фоновое поведение (звонки/push после ребута до первой разблокировки не работают) —
* включать через настройку только после подтверждения владельцем.
*/
fun isPasswordBindingEnabled(): Boolean =
prefs.getBoolean(KEY_BIND_PASSWORD, false) && isEnabled() && isBiometricOnly()
fun setPasswordBinding(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BIND_PASSWORD, enabled).apply()
}
fun disable() { fun disable() {
prefs.edit() prefs.edit()
.putBoolean(KEY_ENABLED, false) .putBoolean(KEY_ENABLED, false)
.remove(KEY_PIN_HASH) .remove(KEY_PIN_HASH)
.putBoolean(KEY_BIOMETRIC, false) .putBoolean(KEY_BIOMETRIC, false)
.putBoolean(KEY_BIOMETRIC_ONLY, false) .putBoolean(KEY_BIOMETRIC_ONLY, false)
.putBoolean(KEY_BIND_PASSWORD, false)
.apply() .apply()
} }
@@ -95,6 +110,7 @@ class AppLockStore(context: Context) {
private const val KEY_BIOMETRIC_ONLY = "biometric_only" private const val KEY_BIOMETRIC_ONLY = "biometric_only"
private const val KEY_PIN_HASH = "pin_hash" private const val KEY_PIN_HASH = "pin_hash"
private const val KEY_SETUP_OFFERED = "setup_offered" private const val KEY_SETUP_OFFERED = "setup_offered"
private const val KEY_BIND_PASSWORD = "bind_password"
@Volatile @Volatile
private var coldStartPending = true private var coldStartPending = true
@@ -0,0 +1,38 @@
package ru.forbion.f7cloud.core.auth
/**
* Process-memory кэш расшифрованного app password для режима биометрической привязки
* (см. [KeystoreCrypto] auth-bound ключ и [AppLockStore.isPasswordBindingEnabled]).
*
* Когда пароль зашифрован ключом, требующим биометрию, его нельзя расшифровать в фоне
* (push, входящие звонки, холодный старт) — там некому пройти BiometricPrompt. Поэтому пароль
* расшифровывается ОДИН раз при разблокировке приложения (CryptoObject в AppLockGate) и живёт
* здесь, в памяти процесса. НИКОГДА не сериализуется на диск.
*
* «Тёплый» = пароль в памяти (приложение разблокировано после старта/ребута). «Холодный» =
* процесс только поднялся или приложение снова заблокировано → фоновые сценарии деградируют
* корректно («Разблокируйте приложение, чтобы принимать звонки»).
*
* При выключенной привязке (дефолт) holder не используется вовсе.
*/
object AppPasswordHolder {
@Volatile
private var password: String? = null
/** True, если пароль расшифрован и лежит в памяти (приложение разблокировано). */
val isWarm: Boolean
get() = password != null
/** Кладёт расшифрованный пароль в память (вызывается после успешной биометрии при разблокировке). */
fun set(value: String) {
password = value
}
/** Возвращает пароль из памяти или null, если «холодно» (не разблокировано в этом процессе). */
fun get(): String? = password
/** Забывает пароль (logout или повторная блокировка приложения). */
fun clear() {
password = null
}
}
@@ -3,14 +3,23 @@ package ru.forbion.f7cloud.core.auth
import android.content.Context import android.content.Context
class AuthStore(context: Context) { class AuthStore(context: Context) {
private val prefs = context.getSharedPreferences("f7_auth", Context.MODE_PRIVATE) private val appContext = context.applicationContext
private val prefs = appContext.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
private val lockStore by lazy { AppLockStore(appContext) }
fun save(session: AuthSession) { fun save(session: AuthSession) {
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto).
// При включённой биометрической привязке — auth-bound ключом (расшифровка требует
// биометрии), иначе — обычным AES-256-GCM.
val encrypted = if (lockStore.isPasswordBindingEnabled()) {
KeystoreCrypto.encryptAuthBound(session.appPassword)
} else {
KeystoreCrypto.encrypt(session.appPassword)
}
prefs.edit() prefs.edit()
.putString("server_url", session.serverUrl.trimEnd('/')) .putString("server_url", session.serverUrl.trimEnd('/'))
.putString("username", session.username.trim()) .putString("username", session.username.trim())
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto) .putString("app_password", encrypted)
.putString("app_password", KeystoreCrypto.encrypt(session.appPassword))
.putBoolean("trust_all_certs", session.trustAllCerts) .putBoolean("trust_all_certs", session.trustAllCerts)
.putString("dav_user_id", session.davUserId) .putString("dav_user_id", session.davUserId)
.apply() .apply()
@@ -21,13 +30,19 @@ class AuthStore(context: Context) {
val username = prefs.getString("username", null) ?: return null val username = prefs.getString("username", null) ?: return null
val storedPassword = prefs.getString("app_password", null) ?: return null val storedPassword = prefs.getString("app_password", null) ?: return null
val appPassword = if (KeystoreCrypto.isEncrypted(storedPassword)) { val appPassword = when {
KeystoreCrypto.isAuthBound(storedPassword) -> {
// Привязано к биометрии: расшифровать в фоне нельзя. Берём из памяти, если
// приложение уже разблокировано в этом процессе; иначе «холодно» → сессии нет,
// фоновый вызывающий деградирует (`?: return`). Прогрев — в AppLockGate.
AppPasswordHolder.get() ?: return null
}
KeystoreCrypto.isEncrypted(storedPassword) -> {
// Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных // Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных
// данных устройства) — трактуем как «сессии нет», пользователь войдёт заново. // данных устройства) — трактуем как «сессии нет», пользователь войдёт заново.
runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null
} else { }
// Legacy: пароль сохранён plaintext (до шифрования) — используем и МИГРИРУЕМ ниже. else -> storedPassword // Legacy plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
storedPassword
} }
val session = AuthSession( val session = AuthSession(
@@ -37,13 +52,43 @@ class AuthStore(context: Context) {
trustAllCerts = prefs.getBoolean("trust_all_certs", false), trustAllCerts = prefs.getBoolean("trust_all_certs", false),
davUserId = prefs.getString("dav_user_id", null), davUserId = prefs.getString("dav_user_id", null),
) )
if (!KeystoreCrypto.isEncrypted(storedPassword)) { if (!KeystoreCrypto.isEncrypted(storedPassword) && !KeystoreCrypto.isAuthBound(storedPassword)) {
runCatching { save(session) } // одноразовая миграция plaintext → ciphertext runCatching { save(session) } // одноразовая миграция plaintext → ciphertext
} }
return session return session
} }
/**
* Метаданные сессии для гейта блокировки: возвращает сессию даже когда пароль привязан и
* ещё не расшифрован (поле [AuthSession.appPassword] тогда пустое). null — только если входа
* нет вовсе. Отличает «залогинен, но заблокирован» от «не залогинен» для экрана блокировки.
*/
fun loadMetadata(): AuthSession? {
val serverUrl = prefs.getString("server_url", null) ?: return null
val username = prefs.getString("username", null) ?: return null
prefs.getString("app_password", null) ?: return null
return load() ?: AuthSession(
serverUrl = serverUrl,
username = username,
appPassword = "",
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
davUserId = prefs.getString("dav_user_id", null),
)
}
/** Пароль привязан к биометрии, но ещё не расшифрован в память (залогинен, но заблокирован). */
fun isPasswordLocked(): Boolean {
val stored = prefs.getString("app_password", null) ?: return false
return KeystoreCrypto.isAuthBound(stored) && !AppPasswordHolder.isWarm
}
/** Сырой auth-bound шифртекст пароля для расшифровки через CryptoObject (или null). */
fun boundPasswordCiphertext(): String? =
prefs.getString("app_password", null)?.takeIf { KeystoreCrypto.isAuthBound(it) }
fun clear() { fun clear() {
AppPasswordHolder.clear()
KeystoreCrypto.deleteBoundKey()
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
} }
@@ -18,8 +18,6 @@ object AuthVerifier {
session.username, session.username,
session.appPassword, session.appPassword,
session.trustAllCerts, session.trustAllCerts,
// login-верификация: 401 = «неверный пароль» со своим сообщением, НЕ session-expired
throwOnUnauthorized = false,
) )
val request = Request.Builder() val request = Request.Builder()
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json") .url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json")
@@ -1,13 +1,21 @@
package ru.forbion.f7cloud.core.auth package ru.forbion.f7cloud.core.auth
import android.os.Build
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.KeyStore import java.security.KeyStore
import java.security.PrivateKey
import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
/** /**
* Шифрование секретов в покое ключом из AndroidKeystore (AES-256-GCM). * Шифрование секретов в покое ключом из AndroidKeystore (AES-256-GCM).
@@ -31,8 +39,19 @@ object KeystoreCrypto {
private const val TAG_BITS = 128 private const val TAG_BITS = 128
const val PREFIX = "v1:" const val PREFIX = "v1:"
// --- Биометрически привязанный (auth-bound) вариант ---------------------------------------
// RSA-пара: ПУБЛИЧНЫЙ ключ шифрует без авторизации (можно в фоне — миграция/save после входа),
// ПРИВАТНЫЙ требует биометрию (setUserAuthenticationRequired) и расшифровывает ТОЛЬКО через
// BiometricPrompt+CryptoObject. Симметричный AES не подошёл бы: там auth нужен и на шифрование.
private const val BIO_KEY_ALIAS = "f7_auth_master_bio"
private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
const val PREFIX_BIO = "v2b:"
fun isEncrypted(value: String?): Boolean = value != null && value.startsWith(PREFIX) fun isEncrypted(value: String?): Boolean = value != null && value.startsWith(PREFIX)
/** True для шифртекста, привязанного к биометрии (расшифровка требует BiometricPrompt). */
fun isAuthBound(value: String?): Boolean = value != null && value.startsWith(PREFIX_BIO)
/** Шифрует строку; результат — с префиксом [PREFIX]. */ /** Шифрует строку; результат — с префиксом [PREFIX]. */
fun encrypt(plaintext: String): String { fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION) val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -76,4 +95,87 @@ object KeystoreCrypto {
) )
return generator.generateKey() return generator.generateKey()
} }
// --- Auth-bound (RSA) ----------------------------------------------------------------------
/**
* Шифрует пароль публичным ключом привязанной пары. Авторизация НЕ требуется (публичный ключ),
* поэтому вызывается откуда угодно, включая фон. Результат — с префиксом [PREFIX_BIO].
*/
fun encryptAuthBound(plaintext: String): String {
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
// Публичный ключ из Keystore переупаковываем через дефолтный провайдер: иначе Cipher из
// AndroidKeyStore на init может «прицепить» auth-требование и к шифрованию.
val publicKey = getOrCreateBioEntry().certificate.publicKey
val unrestricted = KeyFactory.getInstance(publicKey.algorithm)
.generatePublic(X509EncodedKeySpec(publicKey.encoded))
cipher.init(Cipher.ENCRYPT_MODE, unrestricted, oaepParams())
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return PREFIX_BIO + Base64.encodeToString(ct, Base64.NO_WRAP)
}
/**
* Готовит Cipher для расшифровки привязанного шифртекста (DECRYPT + приватный ключ). Его нужно
* обернуть в `BiometricPrompt.CryptoObject`, показать промпт и на успехе вызвать
* [finishBoundDecrypt]. Бросает, если ключ инвалидирован (сменилась биометрия/сброс) —
* вызывающий трактует как «нужен повторный вход».
*/
fun initBoundDecryptCipher(stored: String): Cipher {
require(isAuthBound(stored)) { "not an auth-bound value" }
val privateKey = getOrCreateBioEntry().privateKey as PrivateKey
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams())
return cipher
}
/**
* Завершает расшифровку после успешной биометрии — [cipher] должен прийти из
* `result.cryptoObject.cipher`, авторизованного пользователем.
*/
fun finishBoundDecrypt(cipher: Cipher, stored: String): String {
require(isAuthBound(stored)) { "not an auth-bound value" }
val ct = Base64.decode(stored.removePrefix(PREFIX_BIO), Base64.NO_WRAP)
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
/** Удаляет привязанный ключ (при отключении привязки/повторном входе). */
fun deleteBoundKey() {
runCatching {
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(BIO_KEY_ALIAS)
}
}
// OAEP с MGF1-SHA1 задаём явно на обеих сторонах: AndroidKeyStore внутри использует SHA-1 для
// MGF1 даже при digest SHA-256 — без этого расшифровка падает несовпадением параметров.
private fun oaepParams(): OAEPParameterSpec =
OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
private fun getOrCreateBioEntry(): KeyStore.PrivateKeyEntry {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(ks.getEntry(BIO_KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry)?.let { return it }
val generator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_RSA,
ANDROID_KEYSTORE,
)
val builder = KeyGenParameterSpec.Builder(
BIO_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setKeySize(2048)
.setDigests(KeyProperties.DIGEST_SHA256)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.setUserAuthenticationRequired(true)
// Смена/добавление биометрии инвалидирует ключ → пароль недоступен → повторный вход.
.setInvalidatedByBiometricEnrollment(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Окно валидности 0 = авторизация на КАЖДОЕ использование (через CryptoObject),
// только сильная биометрия (Class 3) — обязательна для CryptoObject в Keystore.
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
}
// На API < 30 отсутствие setUserAuthenticationValidityDurationSeconds(>0) уже означает
// per-use auth, совместимую с CryptoObject.
generator.initialize(builder.build())
generator.generateKeyPair()
return ks.getEntry(BIO_KEY_ALIAS, null) as KeyStore.PrivateKeyEntry
}
} }
@@ -22,6 +22,9 @@ object OcsUserResolver {
.applyOcsJson() .applyOcsJson()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("User profile HTTP ${response.code}") error("User profile HTTP ${response.code}")
} }
+26
View File
@@ -0,0 +1,26 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.core.data'
compileSdk 36
defaultConfig {
minSdk 26
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:database')
implementation project(':core:network')
}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
+3 -3
View File
@@ -21,7 +21,7 @@ android {
} }
dependencies { dependencies {
api libs.room.runtime api 'androidx.room:room-runtime:2.7.2'
implementation libs.room.ktx implementation 'androidx.room:room-ktx:2.7.2'
ksp libs.room.compiler ksp 'androidx.room:room-compiler:2.7.2'
} }
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:FilesDao.kt$FilesDao$("SELECT * FROM files WHERE serverUrl = :serverUrl AND username = :username ORDER BY isDirectory DESC, name ASC")</ID>
<ID>MaximumLineLength:FilesDao.kt$FilesDao$ </ID>
</CurrentIssues>
</SmellBaseline>
+6 -6
View File
@@ -25,11 +25,11 @@ android {
} }
dependencies { dependencies {
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.foundation implementation 'androidx.compose.foundation:foundation'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.coil.compose implementation 'io.coil-kt:coil-compose:2.6.0'
implementation libs.coil.svg implementation 'io.coil-kt:coil-svg:2.6.0'
} }
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp)</ID>
<ID>ArgumentListWrapping:F7Typography.kt$(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp)</ID>
<ID>ImportOrdering:F7Components.kt$import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.OutlinedButton import androidx.compose.material3.TextButton import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.input.KeyboardCapitalization 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.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.window.Dialog</ID>
<ID>LoopWithTooManyJumpStatements:F7OverlayNavigation.kt$while</ID>
<ID>MatchingDeclarationName:F7MobileBottomBar.kt$F7BottomBarActions</ID>
<ID>MatchingDeclarationName:F7OverlayNavigation.kt$F7OverlayNavigationScope</ID>
<ID>MaximumLineLength:F7Typography.kt$ </ID>
<ID>NoUnusedImports:F7NotificationRow.kt$ru.forbion.f7cloud.core.designsystem.F7NotificationRow.kt</ID>
<ID>NoUnusedImports:F7Typography.kt$ru.forbion.f7cloud.core.designsystem.F7Typography.kt</ID>
<ID>UnusedParameter:F7AppMenuSheet.kt$onDismiss: () -&gt; Unit</ID>
<ID>UnusedParameter:F7MobileBottomBar.kt$userId: String</ID>
</CurrentIssues>
</SmellBaseline>
@@ -9,12 +9,10 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -25,10 +23,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -39,10 +35,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -57,18 +51,11 @@ private val BottomBarReserve: Dp = 90.dp
private val MenuIconSize = 62.dp private val MenuIconSize = 62.dp
private val MenuGridGap = 20.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( data class F7AppMenuItem(
val label: String, val label: String,
val iconUrl: String, val iconUrl: String,
val selected: Boolean, val selected: Boolean,
val externalUrl: String? = null, val externalUrl: String? = null,
// Локальная иконка вместо серверной (для нативных пунктов — Уведомления/Настройки),
// рисуется в круглом бейдже с зелёной обводкой под стиль glass-иконок.
val localIcon: ImageVector? = null,
) )
@Composable @Composable
@@ -91,26 +78,6 @@ fun F7AppMenuSheet(
} }
val base = serverUrl.trimEnd('/') val base = serverUrl.trimEnd('/')
Box(modifier = modifier.fillMaxSize()) {
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.18f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
)
}
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(250)) + slideInVertically( enter = fadeIn(tween(250)) + slideInVertically(
@@ -121,45 +88,30 @@ fun F7AppMenuSheet(
animationSpec = tween(300), animationSpec = tween(300),
targetOffsetY = { it }, targetOffsetY = { it },
), ),
modifier = Modifier.align(Alignment.BottomCenter), modifier = modifier,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.fillMaxHeight(MenuSheetHeightFraction)
.padding(bottom = BottomBarReserve) .padding(bottom = BottomBarReserve)
.navigationBarsPadding() .navigationBarsPadding()
.shadow(16.dp, MenuSheetShape, clip = false)
.clip(MenuSheetShape)
.background(F7Colors.Background) .background(F7Colors.Background)
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), .padding(start = 16.dp, end = 16.dp, top = 16.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( F7AppMenuSearchField(
serverUrl = base, serverUrl = base,
value = searchQuery, value = searchQuery,
onValueChange = { searchQuery = it }, onValueChange = { searchQuery = it },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 20.dp), .padding(bottom = 24.dp),
) )
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(MenuGridGap), horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
verticalArrangement = Arrangement.spacedBy(MenuGridGap), verticalArrangement = Arrangement.spacedBy(MenuGridGap),
contentPadding = PaddingValues(horizontal = 2.dp), contentPadding = PaddingValues(horizontal = 2.dp),
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxWidth()
.weight(1f),
) { ) {
itemsIndexed( itemsIndexed(
items = filteredItems, items = filteredItems,
@@ -184,7 +136,6 @@ fun F7AppMenuSheet(
} }
} }
} }
}
@Composable @Composable
private fun F7AppMenuSearchField( private fun F7AppMenuSearchField(
@@ -244,39 +195,17 @@ private fun F7AppMenuGridItem(
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .width(MenuIconSize)
.clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick),
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
val localIcon = item.localIcon
if (localIcon != null) {
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
Box(
modifier = Modifier
.size(MenuIconSize)
.clip(CircleShape)
.background(Color.White)
.border(1.5.dp, F7Colors.Green30, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
localIcon,
contentDescription = item.label,
tint = F7Colors.Primary,
modifier = Modifier.size(MenuIconSize * 0.46f),
)
}
} else {
AsyncImage( AsyncImage(
model = item.iconUrl, model = item.iconUrl,
contentDescription = item.label, contentDescription = item.label,
modifier = Modifier.size(MenuIconSize), modifier = Modifier.size(MenuIconSize),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
}
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.labelLarge.copy( style = MaterialTheme.typography.labelLarge.copy(
@@ -3,9 +3,10 @@ package ru.forbion.f7cloud.core.designsystem
enum class F7BottomBarSlot { enum class F7BottomBarSlot {
Chats, Chats,
NavBack, NavBack,
SectionMail, Create,
SectionCards, Profile,
SectionConferences, Notifications,
Settings,
Menu, Menu,
} }
@@ -15,22 +16,72 @@ data class F7BottomBarConfig(
val buttonCount: Int get() = slots.size val buttonCount: Int get() = slots.size
companion object { companion object {
// Единая навигационная панель по дизайну «Новое меню»:
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
@Suppress("UNUSED_PARAMETER")
fun forContext( fun forContext(
tabKey: String, tabKey: String,
talkInRoom: Boolean, talkInRoom: Boolean,
): F7BottomBarConfig = F7BottomBarConfig( ): 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( listOf(
F7BottomBarSlot.NavBack, F7BottomBarSlot.NavBack,
F7BottomBarSlot.SectionMail, F7BottomBarSlot.Profile,
F7BottomBarSlot.SectionCards, F7BottomBarSlot.Notifications,
F7BottomBarSlot.SectionConferences, 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, F7BottomBarSlot.Menu,
), ),
) )
} }
} }
}
@@ -3,25 +3,10 @@ package ru.forbion.f7cloud.core.designsystem
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
/** /**
* Palette from themes/forbion. Base-токены — из Figma «Брендбук» (палитра владельца, 2026-07-08), * Palette from themes/forbion (mobile + f7support), light theme.
* семантические — поверх Base. Светлая тема.
*/ */
object F7Colors { object F7Colors {
// --- Base palette (Figma «Брендбук») — точные значения --- val Primary = Color(0xFF70B62B)
val Black = Color(0xFF151515) // Base/Black — основной текст
val Black2 = Color(0xFF1F1F1F) // Base/Black 2
val Grey1 = Color(0xFF808080) // Base/Grey 1 — вторичный текст
val Grey2 = Color(0xFFF5F5F5) // Base/Grey 2 — приглушённый фон
val Grey3 = Color(0xFFE6E6E6) // Base/Grey 3 — границы
val Green = Color(0xFF70B62B) // Base/Green — primary
val Green30 = Color(0x4D70B62B) // Base/Green 30%
val Green10 = Color(0x1A70B62B) // Base/Green 10%
val Yellow = Color(0xFFF6E120) // Base/Yellow (тег «Позже» и т.п.)
val Purple = Color(0xFF9747FF) // Base/Purple (тег «Личное» и т.п.)
val Red = Color(0xFFFF7A66) // Base/Red — ошибки/удаление (дизайн!)
val Red10 = Color(0x1AFF7A66) // Base/Red 10% — фон ошибки
val Primary = Green
val PrimaryHover = Color(0xFF6FAF2E) val PrimaryHover = Color(0xFF6FAF2E)
val PrimaryDark = Color(0xFF5E922B) val PrimaryDark = Color(0xFF5E922B)
val PrimaryLight = Color(0xFFECF9DE) val PrimaryLight = Color(0xFFECF9DE)
@@ -30,21 +15,20 @@ object F7Colors {
val Background = Color(0xFFFBFBFB) val Background = Color(0xFFFBFBFB)
val Surface = Color(0xFFFFFFFF) val Surface = Color(0xFFFFFFFF)
val SurfaceMuted = Grey2 val SurfaceMuted = Color(0xFFF5F5F5)
val TextPrimary = Black val TextPrimary = Color(0xFF151515)
val TextSecondary = Grey1 val TextSecondary = Color(0xFF808080)
val TextMuted = Color(0xFF8C8C8C) val TextMuted = Color(0xFF8C8C8C)
val TextOnPrimary = Color(0xFFFFFFFF) val TextOnPrimary = Color(0xFFFFFFFF)
val Border = Grey3 val Border = Color(0xFFE6E6E6)
val BorderLight = Color(0xFFE0E0E0) val BorderLight = Color(0xFFE0E0E0)
val SecondaryButtonBg = Color(0xFFFDFDFD) val SecondaryButtonBg = Color(0xFFFDFDFD)
val SecondaryButtonBorder = Grey3 val SecondaryButtonBorder = Color(0xFFE6E6E6)
// Дизайн-красный #FF7A66 (был #D74642 — расходился с макетом) val Error = Color(0xFFD74642)
val Error = Red val ErrorBg = Color(0xFFFFE2E2)
val ErrorBg = Red10
val StatusNew = Color(0xFF2B9AB6) val StatusNew = Color(0xFF2B9AB6)
val StatusProgress = Color(0xFF70B62B) val StatusProgress = Color(0xFF70B62B)
@@ -570,10 +570,7 @@ fun F7AppScaffold(
content( content(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
// Отступы под статус-бар (сверху) И системную навигацию (снизу), .f7SafeTopInsets(),
// иначе на edge-to-edge (targetSdk 36) контент залезает под панели.
.f7SafeTopInsets()
.f7SafeBottomInsets(),
) )
} }
Box( Box(
@@ -1,53 +0,0 @@
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,25 +21,21 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
data class F7BottomBarActions( data class F7BottomBarActions(
val onChatsClick: () -> Unit = {}, val onChatsClick: () -> Unit = {},
val onNavBackClick: () -> Unit = {}, val onNavBackClick: () -> Unit = {},
val onMailClick: () -> Unit = {}, val onCreateClick: () -> Unit = {},
val onCardsClick: () -> Unit = {}, val onProfileClick: () -> Unit = {},
val onConferencesClick: () -> Unit = {}, val onNotificationsClick: () -> Unit = {},
val onSettingsClick: () -> Unit = {},
val onMenuClick: () -> Unit = {}, val onMenuClick: () -> Unit = {},
) )
private val BottomBarButtonSize = 55.dp private val BottomBarButtonSize = 55.dp
private val BottomBarIconSize = 34.dp private val BottomBarIconSize = 24.dp
// Иконки-разделы (Почта/Карточки/Конференции) — серверные SVG с внутренним
// «воздухом», поэтому рендерим крупнее, чтобы заполняли кружок.
private val BottomBarSectionIconSize = 46.dp
private val BottomBarGap = 8.dp private val BottomBarGap = 8.dp
private val BottomBarOuterPaddingH = 6.dp private val BottomBarOuterPaddingH = 6.dp
private val BottomBarOuterPaddingV = 6.dp private val BottomBarOuterPaddingV = 6.dp
@@ -47,6 +43,9 @@ private val BottomBarButtonShape = RoundedCornerShape(100.dp)
private val BottomBarBorderBrush = Brush.linearGradient( private val BottomBarBorderBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)), listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
) )
private val BottomBarHighlightBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
)
@Composable @Composable
fun F7MobileBottomBar( fun F7MobileBottomBar(
@@ -54,7 +53,6 @@ fun F7MobileBottomBar(
userId: String, userId: String,
config: F7BottomBarConfig, config: F7BottomBarConfig,
actions: F7BottomBarActions, actions: F7BottomBarActions,
activeTabKey: String = "",
menuOpen: Boolean = false, menuOpen: Boolean = false,
chatsHighlighted: Boolean = false, chatsHighlighted: Boolean = false,
navBackHighlighted: Boolean = false, navBackHighlighted: Boolean = false,
@@ -89,31 +87,36 @@ fun F7MobileBottomBar(
"$base/themes/forbion/images/header/chat-icon-gray.svg" "$base/themes/forbion/images/header/chat-icon-gray.svg"
}, },
contentDescription = "Чаты", contentDescription = "Чаты",
highlighted = chatsHighlighted,
onClick = actions.onChatsClick, onClick = actions.onChatsClick,
) )
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot( F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg", iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
contentDescription = "Папки", contentDescription = "Папки",
highlighted = navBackHighlighted,
iconRotation = if (navBackHighlighted) 180f else 0f, iconRotation = if (navBackHighlighted) 180f else 0f,
onClick = actions.onNavBackClick, onClick = actions.onNavBackClick,
) )
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot( F7BottomBarSlot.Create -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg", iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
contentDescription = "Почта", contentDescription = "Создать",
iconSize = BottomBarSectionIconSize, onClick = actions.onCreateClick,
onClick = actions.onMailClick,
) )
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot( F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg", iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
contentDescription = "Карточки", contentDescription = "Профиль",
iconSize = BottomBarSectionIconSize, onClick = actions.onProfileClick,
onClick = actions.onCardsClick,
) )
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot( F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg", iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
contentDescription = "Конференции", contentDescription = "Уведомления",
iconSize = BottomBarSectionIconSize, showBadge = showNotificationBadge,
onClick = actions.onConferencesClick, onClick = actions.onNotificationsClick,
)
F7BottomBarSlot.Settings -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/setting-menu-icon.svg",
contentDescription = "Настройки",
onClick = actions.onSettingsClick,
) )
F7BottomBarSlot.Menu -> F7BottomBarIconSlot( F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
iconUrl = if (menuOpen) { iconUrl = if (menuOpen) {
@@ -122,7 +125,7 @@ fun F7MobileBottomBar(
"$base/themes/forbion/images/header/menu-burger-gray.svg" "$base/themes/forbion/images/header/menu-burger-gray.svg"
}, },
contentDescription = "Меню", contentDescription = "Меню",
showBadge = showNotificationBadge, highlighted = menuOpen,
onClick = actions.onMenuClick, onClick = actions.onMenuClick,
) )
} }
@@ -135,16 +138,22 @@ private fun F7BottomBarIconSlot(
iconUrl: String, iconUrl: String,
contentDescription: String, contentDescription: String,
onClick: () -> Unit, onClick: () -> Unit,
highlighted: Boolean = false,
iconRotation: Float = 0f, iconRotation: Float = 0f,
showBadge: Boolean = false, showBadge: Boolean = false,
iconSize: Dp = BottomBarIconSize,
) { ) {
// Зелёной подложки под активной кнопкой быть не должно — фон всегда нейтральный. val bg = if (highlighted) BottomBarHighlightBrush else null
Box( Box(
modifier = Modifier modifier = Modifier
.size(BottomBarButtonSize) .size(BottomBarButtonSize)
.clip(BottomBarButtonShape) .clip(BottomBarButtonShape)
.background(Color(0x99FFFFFF), BottomBarButtonShape) .then(
if (bg != null) {
Modifier.background(bg, BottomBarButtonShape)
} else {
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
},
)
.border( .border(
width = 1.dp, width = 1.dp,
brush = BottomBarBorderBrush, brush = BottomBarBorderBrush,
@@ -161,7 +170,7 @@ private fun F7BottomBarIconSlot(
model = iconUrl, model = iconUrl,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = Modifier modifier = Modifier
.size(iconSize) .size(BottomBarIconSize)
.graphicsLayer { rotationZ = iconRotation }, .graphicsLayer { rotationZ = iconRotation },
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
@@ -1,24 +0,0 @@
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)
}
}
}
@@ -1,31 +0,0 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.ui.unit.dp
/**
* Токены отступов и радиусов (4dp-сетка). До этого отступы задавались числами по месту
* (аудит: ~1087 инлайновых .dp без токенов) — единая шкала для попиксельной вёрстки по Figma.
* Точные значения конкретных экранов сверяются с макетом; эти — базовая сетка.
*/
object F7Spacing {
val none = 0.dp
val xxs = 2.dp
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 20.dp
val xxl = 24.dp
val xxxl = 32.dp
/** Горизонтальные поля экрана (мобильный контент). */
val screenHorizontal = 16.dp
}
object F7Radius {
val none = 0.dp
val sm = 6.dp
val md = 10.dp
val lg = 14.dp
val pill = 999.dp
}
+3 -6
View File
@@ -23,10 +23,7 @@ android {
} }
dependencies { dependencies {
implementation libs.coroutines.android implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation libs.json implementation 'org.json:json:20240303'
api libs.okhttp api 'com.squareup.okhttp3:okhttp:4.12.0'
testImplementation libs.junit
testImplementation libs.okhttp.mockwebserver
} }
-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:CalendarIcs.kt$CalendarIcs$(',')</ID>
<ID>ArgumentListWrapping:CalendarIcs.kt$CalendarIcs$(it.trim())</ID>
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>CyclomaticComplexMethod:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</ID>
<ID>ImportOrdering:CalendarIcsTest.kt$import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import org.junit.Test</ID>
<ID>LargeClass:CalDavClient.kt$CalDavClient</ID>
<ID>LoopWithTooManyJumpStatements:NotificationsRepository.kt$NotificationsRepository$for</ID>
<ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>MaximumLineLength:LoginFlowClient.kt$LoginFlowClient$ </ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun &lt;T&gt; parseCalendarData(xml: String, map: (String) -&gt; T?): List&lt;T&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List&lt;DavEvent&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List&lt;DavTask&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List&lt;DavCalendar&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List&lt;DavTrashEvent&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List&lt;Pair&lt;String, String&gt;&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List&lt;DavContact&gt;</ID>
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</ID>
<ID>NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val STATE_TOKEN_REGEX = Regex("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>SpacingBetweenDeclarationsWithAnnotations:UnauthorizedInterceptorTest.kt$UnauthorizedInterceptorTest$@After fun tearDown()</ID>
<ID>UnusedParameter:CalDavClient.kt$CalDavClient$calendar: DavCalendar</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun &lt;T&gt; parseCalendarData(xml: String, map: (String) -&gt; T?): List&lt;T&gt;</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun formatIcsLocal(zoned: ZonedDateTime): String</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun unescapeIcsText(text: String): String</ID>
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsDtEnd = Pattern.compile("DTEND[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsLocation = Pattern.compile("LOCATION:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
<ID>UnusedPrivateProperty:LoginFlowClient.kt$LoginFlowClient$val payload = extractPayload(qrData)</ID>
<ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID>
<ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID>
</CurrentIssues>
</SmellBaseline>
@@ -189,6 +189,7 @@ object CalDavClient {
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType())) .put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV create event HTTP ${response.code}") error("CalDAV create event HTTP ${response.code}")
} }
@@ -243,6 +244,7 @@ object CalDavClient {
.delete() .delete()
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204 && response.code != 404) { if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete event HTTP ${response.code}") error("CalDAV delete event HTTP ${response.code}")
} }
@@ -276,6 +278,7 @@ object CalDavClient {
builder.header("If-None-Match", "*") builder.header("If-None-Match", "*")
} }
client.newCall(builder.build()).execute().use { response -> client.newCall(builder.build()).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV put event HTTP ${response.code}") error("CalDAV put event HTTP ${response.code}")
} }
@@ -311,6 +314,7 @@ object CalDavClient {
.method("MKCALENDAR", body.toRequestBody("application/xml; charset=utf-8".toMediaType())) .method("MKCALENDAR", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV create calendar HTTP ${response.code}") error("CalDAV create calendar HTTP ${response.code}")
} }
@@ -343,6 +347,7 @@ object CalDavClient {
.method("MKCOL", body.toRequestBody("application/xml; charset=utf-8".toMediaType())) .method("MKCOL", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV subscribe calendar HTTP ${response.code}") error("CalDAV subscribe calendar HTTP ${response.code}")
} }
@@ -400,6 +405,7 @@ object CalDavClient {
.header("Destination", destinationHref) .header("Destination", destinationHref)
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV move HTTP ${response.code}") error("CalDAV move HTTP ${response.code}")
} }
@@ -575,9 +581,25 @@ object CalDavClient {
) )
} }
// Единый парсер дат — в CalendarIcs (учитывает Z/TZID/floating). Раньше здесь была fun parseIcsInstant(raw: String): Instant? {
// расходящаяся копия (трактовала всё как UTC) — источник рассинхрона календаря и задач. val value = raw.trim()
fun parseIcsInstant(raw: String): Instant? = CalendarIcs.parseIcsInstant(raw) if (value.isBlank()) return null
return runCatching {
when {
value.contains('T') -> {
val clean = value.replace("Z", "", ignoreCase = true).take(15)
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
.toInstant(ZoneOffset.UTC)
}
value.length >= 8 -> {
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
}
else -> null
}
}.getOrNull()
}
private fun formatIcsUtc(instant: Instant): String = formatCalDavTime(instant) private fun formatIcsUtc(instant: Instant): String = formatCalDavTime(instant)
@@ -638,6 +660,7 @@ object CalDavClient {
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType())) .put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV create task HTTP ${response.code}") error("CalDAV create task HTTP ${response.code}")
} }
@@ -684,6 +707,7 @@ object CalDavClient {
.put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType())) .put(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204) { if (response.code !in 200..299 && response.code != 204) {
error("CalDAV update task HTTP ${response.code}") error("CalDAV update task HTTP ${response.code}")
} }
@@ -697,6 +721,7 @@ object CalDavClient {
.delete() .delete()
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204 && response.code != 404) { if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete task HTTP ${response.code}") error("CalDAV delete task HTTP ${response.code}")
} }
@@ -756,6 +781,7 @@ object CalDavClient {
private fun execute(client: OkHttpClient, req: Request): String { private fun execute(client: OkHttpClient, req: Request): String {
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val code = response.code val code = response.code
if (code !in 200..299 && code != 207) { if (code !in 200..299 && code != 207) {
error("CalDAV error HTTP $code") error("CalDAV error HTTP $code")
@@ -44,10 +44,7 @@ data class CalendarEventData(
object CalendarIcs { object CalendarIcs {
private val veventBlock = Pattern.compile("BEGIN:VEVENT([\\s\\S]*?)END:VEVENT", Pattern.CASE_INSENSITIVE) private val veventBlock = Pattern.compile("BEGIN:VEVENT([\\s\\S]*?)END:VEVENT", Pattern.CASE_INSENSITIVE)
private val linePattern = Pattern.compile("^([A-Z0-9-]+)(?:;[^:]*)?:(.*)$", Pattern.MULTILINE)
/** Одно ICS-свойство: имя, параметры (TZID/CN/PARTSTAT…) и значение. Параметры НЕ теряем —
* раньше их срезал regex, из-за чего пропадал TZID и схлопывались ATTENDEE. */
private data class IcsProp(val name: String, val params: Map<String, String>, val value: String)
fun parseAll(ics: String): List<CalendarEventData> { fun parseAll(ics: String): List<CalendarEventData> {
val unfolded = unfold(ics) val unfolded = unfold(ics)
@@ -73,22 +70,23 @@ object CalendarIcs {
} }
private fun parseVEventBlock(block: String): CalendarEventData? { private fun parseVEventBlock(block: String): CalendarEventData? {
val props = parseProps(block) val lines = parseLines(block)
// первое значение на имя — для простых одиночных свойств (SUMMARY/UID/…)
val lines = props.associate { it.name to it.value }
val uid = lines["UID"]?.trim().orEmpty() val uid = lines["UID"]?.trim().orEmpty()
if (uid.isBlank()) return null if (uid.isBlank()) return null
val dtStart = props.firstOrNull { it.name == "DTSTART" } ?: return null val dtStartRaw = lines["DTSTART"].orEmpty()
val start = parseIcsInstant(dtStart.value, dtStart.params["TZID"]) ?: return null val start = parseIcsInstant(dtStartRaw) ?: return null
val allDay = dtStart.params["VALUE"].equals("DATE", ignoreCase = true) || !dtStart.value.contains('T') val allDay = !dtStartRaw.contains('T')
val dtEnd = props.firstOrNull { it.name == "DTEND" } val endRaw = lines["DTEND"]
val end = dtEnd?.let { parseIcsInstant(it.value, it.params["TZID"]) } val end = if (endRaw != null) {
?: start.plusSeconds(if (allDay) 86400 else 3600) parseIcsInstant(endRaw) ?: start.plusSeconds(if (allDay) 86400 else 3600)
// каждый ATTENDEE — со своими параметрами (CN/PARTSTAT/ROLE), не схлопываем } else {
val attendees = props.filter { it.name == "ATTENDEE" }.mapNotNull { parseAttendee(it) } start.plusSeconds(if (allDay) 86400 else 3600)
val organizerProp = props.firstOrNull { it.name == "ORGANIZER" } }
val orgEmail = organizerProp?.value?.substringAfter("mailto:", organizerProp.value)?.trim().orEmpty() val attendees = lines.entries
val orgName = organizerProp?.params?.get("CN")?.let(::unescape).orEmpty() .filter { it.key.startsWith("ATTENDEE") }
.mapNotNull { parseAttendeeLine(it.key, it.value) }
val organizer = lines["ORGANIZER"].orEmpty()
val (orgEmail, orgName) = parseOrganizer(organizer)
val alarms = parseAlarms(block) val alarms = parseAlarms(block)
val conference = lines.entries val conference = lines.entries
.firstOrNull { it.key.startsWith("CONFERENCE") } .firstOrNull { it.key.startsWith("CONFERENCE") }
@@ -175,71 +173,52 @@ object CalendarIcs {
fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile" fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile"
/** fun parseIcsInstant(raw: String): Instant? {
* ICS date-time → Instant с учётом зоны:
* - суффикс `Z` → UTC (раньше срезался и трактовался как локальное — баг);
* - параметр TZID → указанная зона;
* - иначе (floating) → локальная зона устройства;
* - только дата (VALUE=DATE, 8 цифр) → начало дня в зоне (TZID или локальной).
*/
fun parseIcsInstant(raw: String, tzId: String? = null): Instant? {
val value = raw.trim() val value = raw.trim()
if (value.isBlank()) return null if (value.isBlank()) return null
val paramZone = tzId?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return runCatching { return runCatching {
when { when {
value.contains('T') -> { value.contains('T') -> {
val hasZ = value.endsWith("Z", ignoreCase = true) val clean = value.replace("Z", "", ignoreCase = true).take(15)
val clean = value.trimEnd('Z', 'z').take(15) LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
val ldt = LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")) .atZone(ZoneId.systemDefault()).toInstant()
val zone = when {
hasZ -> ZoneOffset.UTC
else -> paramZone ?: ZoneId.systemDefault()
}
ldt.atZone(zone).toInstant()
} }
value.length >= 8 -> { value.length >= 8 -> {
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE) LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(paramZone ?: ZoneId.systemDefault()).toInstant() .atStartOfDay(ZoneId.systemDefault()).toInstant()
} }
else -> null else -> null
} }
}.getOrNull() }.getOrNull()
} }
/** Разбор ICS-строк в свойства с параметрами. Сворачивание строк уже снято в [unfold]. */ private fun parseLines(block: String): Map<String, String> {
private fun parseProps(block: String): List<IcsProp> { val map = mutableMapOf<String, String>()
val out = mutableListOf<IcsProp>() unfold(block).lineSequence().forEach { line ->
unfold(block).lineSequence().forEach { raw -> val m = linePattern.matcher(line.trim())
val line = raw.trim() if (m.find()) {
val colon = line.indexOf(':') val key = m.group(1)?.uppercase().orEmpty()
if (colon <= 0) return@forEach val value = m.group(2).orEmpty()
val head = line.substring(0, colon) // NAME;PARAM=VAL;PARAM2=VAL map[key] = if (map.containsKey(key)) "${map[key]}\n$value" else value
val value = line.substring(colon + 1)
val parts = head.split(';')
val name = parts[0].uppercase()
val params = parts.drop(1).mapNotNull { p ->
val eq = p.indexOf('=')
if (eq <= 0) null else p.substring(0, eq).uppercase() to p.substring(eq + 1).trim('"')
}.toMap()
out += IcsProp(name, params, value)
} }
return out }
return map
} }
private fun parseLines(block: String): Map<String, String> = private fun parseAttendeeLine(key: String, value: String): CalendarAttendeeData? {
parseProps(block).associate { it.name to it.value } val email = value.substringAfter("mailto:", value).trim()
private fun parseAttendee(prop: IcsProp): CalendarAttendeeData? {
val email = prop.value.substringAfter("mailto:", prop.value).trim()
if (email.isBlank()) return null if (email.isBlank()) return null
return CalendarAttendeeData( val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1)?.let(::unescape)
email = email, val partStat = Regex("PARTSTAT=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "NEEDS-ACTION"
displayName = prop.params["CN"]?.let(::unescape).orEmpty(), val role = Regex("ROLE=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "REQ-PARTICIPANT"
partStat = prop.params["PARTSTAT"] ?: "NEEDS-ACTION", val rsvp = !key.contains("RSVP=FALSE", ignoreCase = true)
role = prop.params["ROLE"] ?: "REQ-PARTICIPANT", return CalendarAttendeeData(email = email, displayName = cn.orEmpty(), partStat = partStat, role = role, rsvp = rsvp)
rsvp = !prop.params["RSVP"].equals("FALSE", ignoreCase = true), }
)
private fun parseOrganizer(value: String): Pair<String, String> {
val email = value.substringAfter("mailto:", value).trim()
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(value)?.groupValues?.get(1)?.let(::unescape).orEmpty()
return email to cn
} }
private fun parseAlarms(block: String): List<CalendarAlarmData> { private fun parseAlarms(block: String): List<CalendarAlarmData> {
@@ -286,29 +265,8 @@ object CalendarIcs {
private fun escape(text: String): String = private fun escape(text: String): String =
text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;") text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
/** Single-pass: последовательные replace ломались на экранированном бэкслеше (`\\n` → перенос). */ private fun unescape(text: String): String =
private fun unescape(text: String): String { text.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
if (text.indexOf('\\') < 0) return text
val sb = StringBuilder(text.length)
var i = 0
while (i < text.length) {
val c = text[i]
if (c == '\\' && i + 1 < text.length) {
when (val n = text[i + 1]) {
'n', 'N' -> sb.append('\n')
',' -> sb.append(',')
';' -> sb.append(';')
'\\' -> sb.append('\\')
else -> sb.append(n)
}
i += 2
} else {
sb.append(c)
i++
}
}
return sb.toString()
}
private fun formatUtc(instant: Instant): String = private fun formatUtc(instant: Instant): String =
instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")) instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
@@ -90,6 +90,7 @@ object CardDavClient {
.put(vcard.toRequestBody("text/vcard; charset=utf-8".toMediaType())) .put(vcard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("Не удалось создать контакт (HTTP ${response.code})") error("Не удалось создать контакт (HTTP ${response.code})")
} }
@@ -162,6 +163,7 @@ object CardDavClient {
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType())) .method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(req).execute().use { response -> client.newCall(req).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val code = response.code val code = response.code
if (code !in 200..299 && code != 207) { if (code !in 200..299 && code != 207) {
error("CardDAV error HTTP $code") error("CardDAV error HTTP $code")
@@ -27,6 +27,9 @@ object DavClient {
.method("MKCOL", null) .method("MKCOL", null)
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("DAV MKCOL HTTP ${response.code}") error("DAV MKCOL HTTP ${response.code}")
} }
@@ -43,6 +46,9 @@ object DavClient {
.put(body) .put(body)
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("DAV upload HTTP ${response.code}") error("DAV upload HTTP ${response.code}")
} }
@@ -70,6 +76,9 @@ object DavClient {
.method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType())) .method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code == 401) {
throw UnauthorizedException()
}
val code = response.code val code = response.code
if (code !in 200..299 && code != 207) { if (code !in 200..299 && code != 207) {
error("DAV error HTTP $code") error("DAV error HTTP $code")
@@ -173,6 +182,7 @@ object DavClient {
.delete() .delete()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 204) { if (response.code !in 200..299 && response.code != 204) {
error("DAV DELETE HTTP ${response.code}") error("DAV DELETE HTTP ${response.code}")
} }
@@ -187,6 +197,7 @@ object DavClient {
.header("Overwrite", "T") .header("Overwrite", "T")
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("DAV MOVE HTTP ${response.code}") error("DAV MOVE HTTP ${response.code}")
} }
@@ -210,6 +221,7 @@ object DavClient {
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType())) .method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code !in 200..299 && response.code != 207) { if (response.code !in 200..299 && response.code != 207) {
error("DAV PROPPATCH HTTP ${response.code}") error("DAV PROPPATCH HTTP ${response.code}")
} }
@@ -187,40 +187,25 @@ object LoginFlowClient {
} }
} }
internal data class CredentialParams( private data class CredentialParams(
val server: String, val server: String,
val user: String, val user: String,
val password: String, val password: String,
) )
/** private fun parseCredentialParams(params: String): CredentialParams? {
* Разбор `server:...&user:...&password:...` (формат nc-login). Значения НЕ split('&'): val values = params.split('&')
* пароль может содержать `&` и `:`, а раньше `split('&')`+`size>3` его резал/ронял вход. if (values.isEmpty() || values.size > 3) return null
* Ищем маркеры `key:` (в начале или после `&`) и берём значение до следующего маркера. var server = ""
*/ var user = ""
internal fun parseCredentialParams(params: String): CredentialParams? { var password = ""
val keys = listOf("server", "user", "password") values.forEach { value ->
data class Marker(val key: String, val at: Int, val valueAt: Int) when {
val markers = mutableListOf<Marker>() value.startsWith("user:") -> user = decode(value.removePrefix("user:"))
for (key in keys) { value.startsWith("server:") -> server = decode(value.removePrefix("server:"))
if (params.startsWith("$key:")) markers += Marker(key, 0, key.length + 1) value.startsWith("password:") -> password = decode(value.removePrefix("password:"))
var idx = params.indexOf("&$key:")
while (idx >= 0) {
markers += Marker(key, idx, idx + 1 + key.length + 1)
idx = params.indexOf("&$key:", idx + 1)
} }
} }
if (markers.isEmpty()) return null
markers.sortBy { it.at }
val map = mutableMapOf<String, String>()
markers.forEachIndexed { i, m ->
val end = if (i + 1 < markers.size) markers[i + 1].at else params.length
// первое вхождение ключа выигрывает (не перезатираем более поздним мусором)
map.putIfAbsent(m.key, decode(params.substring(m.valueAt, end)))
}
val server = map["server"].orEmpty()
val user = map["user"].orEmpty()
val password = map["password"].orEmpty()
if (server.isBlank() || user.isBlank() || password.isBlank()) return null if (server.isBlank() || user.isBlank() || password.isBlank()) return null
return CredentialParams(server, user, password) return CredentialParams(server, user, password)
} }
@@ -1,58 +1,24 @@
package ru.forbion.f7cloud.core.network package ru.forbion.f7cloud.core.network
import android.content.Context
import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* Фабрика HTTP-клиентов. Раньше создавала НОВЫЙ OkHttpClient на КАЖДЫЙ запрос (~32 места) →
* TLS-handshake и новый пул/диспатчер на запрос, утечка ExecutorService. Теперь:
* - один базовый клиент (общий ConnectionPool + Dispatcher + диск-кэш);
* - авторизованные варианты — через `base.newBuilder()` (шарят пул/диспатчер/кэш);
* - экземпляры кэшируются по кредам+таймаутам → переиспользуются, соединения живут.
*
* Публичный API совместим — вызывающие (`newAuthedClient*`) работают как прежде.
* 401→UnauthorizedException централизован через interceptor (`throwOnUnauthorized`, по умолч.
* true) — ручные проверки в репозиториях убраны. Login-пути (AuthVerifier), где 401 = «неверный
* пароль» со своим сообщением, передают `throwOnUnauthorized = false`.
*/
object NetworkFactory { object NetworkFactory {
@Volatile
private var base: OkHttpClient = OkHttpClient()
private val clients = ConcurrentHashMap<String, OkHttpClient>()
/** Вызывать один раз из Application.onCreate — добавляет диск-кэш (нужен cacheDir). */
fun init(context: Context) {
val cacheDir = File(context.applicationContext.cacheDir, "http-cache")
base = OkHttpClient.Builder()
.cache(Cache(cacheDir, 20L * 1024 * 1024)) // 20 МБ
.build()
clients.clear() // пересобрать производные клиенты уже с кэшем
}
fun newAuthedClient( fun newAuthedClient(
username: String, username: String,
appPassword: String, appPassword: String,
trustAllCerts: Boolean = false, trustAllCerts: Boolean = false,
callTimeoutSeconds: Long = 30, callTimeoutSeconds: Long = 30,
readTimeoutSeconds: Long = 30, readTimeoutSeconds: Long = 30,
throwOnUnauthorized: Boolean = true,
): OkHttpClient { ): OkHttpClient {
val key = "$username|$appPassword|$trustAllCerts|$callTimeoutSeconds|$readTimeoutSeconds|$throwOnUnauthorized" return OkHttpClient.Builder()
return clients.getOrPut(key) {
base.newBuilder() // общий пул/диспатчер/кэш базового клиента
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS) .callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS) .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.applyUnsafeSslIfNeeded(trustAllCerts) .applyUnsafeSslIfNeeded(trustAllCerts)
.addInterceptor(BasicAuthInterceptor(username, appPassword)) .addInterceptor(BasicAuthInterceptor(username, appPassword))
.apply { if (throwOnUnauthorized) addInterceptor(UnauthorizedInterceptor) }
.build() .build()
} }
}
/** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */ /** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */
fun newAuthedClientForOffice( fun newAuthedClientForOffice(
@@ -30,6 +30,7 @@ class NotificationsRepository {
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Уведомления HTTP ${response.code}") error("Уведомления HTTP ${response.code}")
} }
@@ -85,6 +86,7 @@ class NotificationsRepository {
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) { if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}") error("Уведомления HTTP ${response.code}")
} }
@@ -1,22 +0,0 @@
package ru.forbion.f7cloud.core.network
import okhttp3.Interceptor
import okhttp3.Response
/**
* Единая точка обработки 401 для авторизованных запросов: бросает [UnauthorizedException]
* (session expired → logout). Заменяет ~70 ручных `if (code == 401) throw ...` по репозиториям.
*
* Подключается в [NetworkFactory] при `throwOnUnauthorized = true` (по умолчанию). Login-пути
* (проверка пароля при входе) НЕ используют этот interceptor — там 401 = «неверный пароль».
*/
object UnauthorizedInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code == 401) {
response.close() // не течём телом — дальше по цепочке оно не читается
throw UnauthorizedException()
}
return response
}
}
@@ -1,104 +0,0 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import org.junit.Test
/**
* Тесты парсера ICS. Фиксируют баги из аудита (2026-07-07): зоны/TZID, ATTENDEE, unescape.
* Даты с суффиксом Z дают абсолютный Instant → не зависят от таймзоны машины.
*/
class CalendarIcsTest {
// --- parseIcsInstant: зоны ---
@Test fun `Z-суффикс парсится как UTC`() {
// Баг: раньше Z срезался и время трактовалось как локальное.
val expected = Instant.parse("2026-07-07T10:00:00Z")
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000Z"))
}
@Test fun `floating без Z — локальная зона`() {
val expected = LocalDateTime.of(2026, 7, 7, 10, 0, 0)
.atZone(ZoneId.systemDefault()).toInstant()
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000"))
}
@Test fun `TZID учитывается`() {
// 12:00 в Москве (UTC+3) == 09:00 UTC
val expected = Instant.parse("2026-07-07T09:00:00Z")
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T120000", "Europe/Moscow"))
}
@Test fun `VALUE=DATE — начало дня`() {
val expected = java.time.LocalDate.of(2026, 7, 7)
.atStartOfDay(ZoneId.systemDefault()).toInstant()
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707"))
}
// --- ATTENDEE: несколько участников с параметрами ---
@Test fun `несколько ATTENDEE не схлопываются и хранят CN-PARTSTAT`() {
val ics = """
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:test-1
DTSTART:20260707T100000Z
SUMMARY:Встреча
ATTENDEE;CN=Иван Петров;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:ivan@f7.ru
ATTENDEE;CN=Мария Сидорова;PARTSTAT=DECLINED;ROLE=OPT-PARTICIPANT:mailto:maria@f7.ru
END:VEVENT
END:VCALENDAR
""".trimIndent()
val event = CalendarIcs.parseSingle(ics)!!
assertEquals(2, event.attendees.size)
val ivan = event.attendees.first { it.email == "ivan@f7.ru" }
assertEquals("Иван Петров", ivan.displayName)
assertEquals("ACCEPTED", ivan.partStat)
val maria = event.attendees.first { it.email == "maria@f7.ru" }
assertEquals("Мария Сидорова", maria.displayName)
assertEquals("DECLINED", maria.partStat)
}
// --- escape/unescape: круговой round-trip со спецсимволами ---
@Test fun `summary со спецсимволами переживает build и parse`() {
val summary = """Смета: 50%, скидка; путь C:\tmp\файл и
перенос""".trimIndent()
val data = CalendarEventData(
uid = "rt-1",
summary = summary,
startEpochMilli = Instant.parse("2026-07-07T10:00:00Z").toEpochMilli(),
endEpochMilli = Instant.parse("2026-07-07T11:00:00Z").toEpochMilli(),
)
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
assertEquals(summary, parsed.summary)
}
@Test fun `unescape не превращает экранированный бэкслеш-n в перенос строки`() {
// literal "\n" (бэкслеш+n) в тексте: escape → "\\n", unescape должен вернуть "\n" (2 символа),
// а не перевод строки. Проверяем через round-trip.
val summary = """путь\name"""
val data = CalendarEventData(
uid = "rt-2",
summary = summary,
startEpochMilli = 0L,
endEpochMilli = 3600_000L,
)
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
assertEquals(summary, parsed.summary)
assertTrue("не должно быть переноса строки", !parsed.summary.contains('\n'))
}
// --- согласованность двух парсеров ---
@Test fun `CalDavClient и CalendarIcs парсят одинаково`() {
val values = listOf("20260707T100000Z", "20260707", "20251231T235900Z")
for (v in values) {
assertEquals("рассинхрон на $v", CalendarIcs.parseIcsInstant(v), CalDavClient.parseIcsInstant(v))
}
}
}
@@ -1,38 +0,0 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Разбор параметров nc-login QR. Фиксирует баг: пароль с `&` терялся (split('&')+size>3). */
class LoginFlowClientTest {
@Test fun `пароль с амперсандом и двоеточием не теряется`() {
val r = LoginFlowClient.parseCredentialParams(
"server:https://cloud.f7.ru&user:ivan&password:a&b:c&d",
)!!
assertEquals("https://cloud.f7.ru", r.server)
assertEquals("ivan", r.user)
assertEquals("a&b:c&d", r.password)
}
@Test fun `порядок полей произвольный`() {
val r = LoginFlowClient.parseCredentialParams(
"user:masha&password:p@ss&server:https://x.ru",
)!!
assertEquals("https://x.ru", r.server)
assertEquals("masha", r.user)
assertEquals("p@ss", r.password)
}
@Test fun `простой пароль без спецсимволов`() {
val r = LoginFlowClient.parseCredentialParams(
"server:https://x.ru&user:u&password:simple",
)!!
assertEquals("simple", r.password)
}
@Test fun `нет обязательного поля — null`() {
assertNull(LoginFlowClient.parseCredentialParams("server:https://x.ru&user:u"))
}
}
@@ -1,29 +0,0 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Test
/** Фиксирует переиспользование клиента и общий пул соединений (было: новый клиент на запрос). */
class NetworkFactoryTest {
@Test fun `одинаковые креды дают ТОТ ЖЕ экземпляр клиента`() {
val a = NetworkFactory.newAuthedClient("user", "pass", false)
val b = NetworkFactory.newAuthedClient("user", "pass", false)
assertSame("клиент должен переиспользоваться, а не создаваться заново", a, b)
}
@Test fun `клиенты шарят общий ConnectionPool и Dispatcher`() {
val a = NetworkFactory.newAuthedClient("u1", "p1", false)
val b = NetworkFactory.newAuthedClient("u2", "p2", false) // другие креды
assertSame(a.connectionPool, b.connectionPool)
assertSame(a.dispatcher, b.dispatcher)
}
@Test fun `разные таймауты — разные клиенты, но общий пул`() {
val normal = NetworkFactory.newAuthedClient("u", "p", false)
val office = NetworkFactory.newAuthedClientForOffice("u", "p", false)
assertNotSame(normal, office)
assertSame(normal.connectionPool, office.connectionPool)
}
}
@@ -1,40 +0,0 @@
package ru.forbion.f7cloud.core.network
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
/** Централизованная обработка 401: interceptor бросает UnauthorizedException вместо ручных проверок. */
class UnauthorizedInterceptorTest {
private lateinit var server: MockWebServer
@Before fun setUp() { server = MockWebServer().apply { start() } }
@After fun tearDown() { server.shutdown() }
private fun call(client: OkHttpClient) =
client.newCall(Request.Builder().url(server.url("/x")).build()).execute()
@Test(expected = UnauthorizedException::class)
fun `401 бросает UnauthorizedException`() {
server.enqueue(MockResponse().setResponseCode(401))
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
call(client)
}
@Test fun `не-401 проходит как обычно`() {
server.enqueue(MockResponse().setResponseCode(200).setBody("ok"))
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
call(client).use { assertEquals(200, it.code) }
}
@Test fun `403 не трактуется как Unauthorized`() {
server.enqueue(MockResponse().setResponseCode(403))
val client = OkHttpClient.Builder().addInterceptor(UnauthorizedInterceptor).build()
call(client).use { assertEquals(403, it.code) }
}
}
+4 -5
View File
@@ -21,9 +21,8 @@ android {
dependencies { dependencies {
implementation project(':core:auth') implementation project(':core:auth')
implementation platform(libs.firebase.bom) implementation 'com.google.firebase:firebase-messaging:24.0.1'
implementation libs.firebase.messaging implementation 'androidx.core:core-ktx:1.15.0'
implementation libs.core.ktx implementation 'androidx.core:core:1.15.0'
implementation libs.core implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation libs.coroutines.android
} }
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:F7IncomingCallQueue.kt$F7IncomingCallQueue$(R.string.incoming_call_subtitle)</ID>
<ID>ArgumentListWrapping:F7IncomingCallQueue.kt$F7IncomingCallQueue$(call.displayName.ifBlank { call.title.ifBlank { context.getString(R.string.incoming_call_subtitle) } })</ID>
<ID>ArgumentListWrapping:F7NotificationChannels.kt$F7NotificationChannels$(SERVER_MESSAGES, messagesName, NotificationManager.IMPORTANCE_HIGH, msgVibration, notificationSound, audio)</ID>
<ID>ComplexCondition:F7PushEventParser.kt$F7PushEventParser$type == "mail" || source == "mail" || lowerUrl.contains("/apps/f7mail") || lowerUrl.contains("/apps/mail")</ID>
<ID>MaximumLineLength:F7IncomingCallQueue.kt$F7IncomingCallQueue$ </ID>
<ID>MaximumLineLength:F7NotificationChannels.kt$F7NotificationChannels$ </ID>
<ID>NestedBlockDepth:F7FirebaseMessagingService.kt$F7FirebaseMessagingService$override fun onMessageReceived(message: RemoteMessage)</ID>
<ID>SpacingBetweenDeclarationsWithComments:F7NotificationChannels.kt$F7NotificationChannels$/** Silent channel: ringtone is played only by [F7IncomingCallRinger]. */</ID>
<ID>UnusedParameter:F7IncomingCallQueue.kt$F7IncomingCallQueue$alert: Boolean</ID>
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val callVibration = longArrayOf(0, 500, 200, 500)</ID>
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val ringtone = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)</ID>
<ID>UnusedPrivateProperty:F7NotificationChannels.kt$F7NotificationChannels$val ringtoneAudio = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build()</ID>
<ID>UseCheckOrError:F7IncomingCallQueue.kt$F7IncomingCallQueue$throw IllegalStateException("NotificationManager unavailable")</ID>
</CurrentIssues>
</SmellBaseline>
@@ -241,8 +241,7 @@ object F7IncomingCallQueue {
.setStyle(callStyle) .setStyle(callStyle)
.setCategory(NotificationCompat.CATEGORY_CALL) .setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX) .setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPublicVersion(genericCallPublic(context, intents.preview))
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
@@ -283,8 +282,7 @@ object F7IncomingCallQueue {
.setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setCategory(NotificationCompat.CATEGORY_CALL) .setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX) .setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPublicVersion(genericCallPublic(context, intents.preview))
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
@@ -306,21 +304,6 @@ object F7IncomingCallQueue {
?: throw IllegalStateException("NotificationManager unavailable") ?: throw IllegalStateException("NotificationManager unavailable")
} }
/**
* Обезличенная (public) версия уведомления звонка для локскрина: показывает лишь «Входящий
* звонок» без имени звонящего/комнаты (минимум информации, решение владельца). Полное имя —
* в полноэкранном UI звонка/после разблокировки. Full-screen intent работает независимо от visibility.
*/
private fun genericCallPublic(context: Context, preview: PendingIntent): android.app.Notification =
NotificationCompat.Builder(context, F7NotificationChannels.CALLS)
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
.setContentTitle("F7cloud")
.setContentText("Входящий звонок")
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentIntent(preview)
.build()
private fun resolveJoinUrl(context: Context, call: PendingCall): String? { private fun resolveJoinUrl(context: Context, call: PendingCall): String? {
val raw = call.acceptUrl?.takeIf { it.isNotBlank() } val raw = call.acceptUrl?.takeIf { it.isNotBlank() }
?: AuthStore(context).load()?.let { session -> ?: AuthStore(context).load()?.let { session ->
@@ -48,10 +48,7 @@ object F7NotificationChannels {
setSound(sound, soundAttrs) setSound(sound, soundAttrs)
} }
setShowBadge(true) setShowBadge(true)
// Приватно на локскрине: система покажет public-версию (generic), а не полный lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
// контент (минимум информации, решение владельца). Public-версия задаётся
// per-notification в F7PushNotificationHelper / F7IncomingCallQueue.
lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE
}, },
) )
} }
@@ -49,21 +49,6 @@ object F7PushNotificationHelper {
} }
val iconRes = context.applicationInfo.icon.takeIf { it != 0 } val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.stat_notify_chat ?: android.R.drawable.stat_notify_chat
// На локскрине — ОБЕЗЛИЧЕННАЯ версия (минимум информации, решение владельца): отправитель
// и текст не утекают на заблокированном экране. Полный контент — после разблокировки.
val genericText = when (type) {
"chat", "room", "message" -> "Новое сообщение"
"call" -> "Входящий звонок"
else -> "Новое уведомление"
}
val publicVersion = NotificationCompat.Builder(context, channel)
.setSmallIcon(iconRes)
.setContentTitle("F7cloud")
.setContentText(genericText)
.setAutoCancel(true)
.setPriority(priority)
.setContentIntent(pending)
.build()
val notification = NotificationCompat.Builder(context, channel) val notification = NotificationCompat.Builder(context, channel)
.setSmallIcon(iconRes) .setSmallIcon(iconRes)
.setContentTitle(title) .setContentTitle(title)
@@ -71,8 +56,6 @@ object F7PushNotificationHelper {
.setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true) .setAutoCancel(true)
.setPriority(priority) .setPriority(priority)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
.setContentIntent(pending) .setContentIntent(pending)
.build() .build()
manager.notify((System.currentTimeMillis() % Int.MAX_VALUE).toInt(), notification) manager.notify((System.currentTimeMillis() % Int.MAX_VALUE).toInt(), notification)
+3
View File
@@ -0,0 +1,3 @@
plugins {
id 'java-library'
}
-56
View File
@@ -1,56 +0,0 @@
# CI — Gitea Actions
Пайплайн: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). Gitea на git.f7cloud.ru — **1.26.1**
(Actions поддерживаются).
## Что делает
| Триггер | Джоба | Действия |
|---|---|---|
| push в `main`, любой PR | `verify` | `testDebugUnitTest` + `:app:lintRelease` + `:app:assembleDebug` (+ артефакт debug-APK) |
| тег `v*` | `release` | подписанный `assembleRelease` + архив исходников (GPL §6) → Gitea Release |
## Предпосылка: self-hosted раннер (инфра ячейки B)
Проект тяжёлый (vendor talk-android + WebRTC), поэтому раннер — **self-hosted** с тёплым
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).
Регистрация — **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
systemctl status act_runner # состояние
journalctl -u act_runner -f # логи
```
## Secrets репозитория (Gitea → Settings → Actions → Secrets)
Для джобы `release`:
- `F7_KEYSTORE_BASE64` — боевой keystore, `base64 -w0 release.jks`
- `F7_KEYSTORE_PASSWORD`, `F7_KEY_ALIAS`, `F7_KEY_PASSWORD` — см. [RELEASE-SIGNING.md](RELEASE-SIGNING.md)
## GPL corresponding source
`scripts/package-source.sh` собирает архив всего исходника (вкл. vendor/talk-android GPL, LICENSE,
NOTICE) — обязательство GPLv3 §6. Работает и вручную:
```bash
scripts/package-source.sh dist/ # → dist/f7cloud-mobile-v<ver>-<rev>-source.tar.gz
```
В CI прикладывается к каждому Gitea Release. До появления раннера — публиковать исходник
вручную этим скриптом к каждой распространяемой сборке.
## Статус
**Активно (2026-07-09):** self-hosted раннер `f7-b-android` зарегистрирован и запущен на инфре B
(scope — репозиторий, host-исполнение). Workflow срабатывает на push/PR (lint+тесты+assembleDebug).
Осталось для джобы `release` по тегу: завести Secrets репозитория (keystore base64 + пароли) —
пока release-сборки делаются вручную на билд-машине тем же ключом.
-49
View File
@@ -1,49 +0,0 @@
# Источник дизайна — Figma (попиксельная мобильная вёрстка)
Задача: мобильный клиент = мобильная версия F7 попиксельно. Источник истины — **Figma роли `design`**
(не боевой forbion; glb устарел). Координация — mail/044 (через integrator-b). Приоритет:
**Файлы → Почта → Конференции**, референс **360dp**, тема светлая.
## Доступ к Figma API
Токен — вне git: `/root/.f7cloud-keys/figma.env` (`FIGMA_TOKEN`, `FIGMA_FILE=ocVUCYCrfFUoqYYvlcLPBF`).
```bash
source /root/.f7cloud-keys/figma.env
curl -H "X-Figma-Token: $FIGMA_TOKEN" "https://api.figma.com/v1/files/$FIGMA_FILE/nodes?ids=<ID>&depth=2"
```
⚠️ **Rate limit:** глубокие сканы всей страницы (`1:90` depth≥3) ловят **429**. Работать ТОЧЕЧНО —
запрашивать конкретные node ID нужного экрана с малой глубиной. Рендер кадра в PNG:
`GET /v1/images/$FIGMA_FILE?ids=<ID>&format=png&scale=2`.
## Карта файла (страницы)
| ID | Страница |
|---|---|
| `1:90` | 🟢 Дизайн облачного рабочего места — ОСНОВНОЙ дизайн (65 секций по модулям) |
| `0:1` | Брендбук (токены: TYPOGRAPHY 1:116, цвета 97:8269) |
| `2095:46379` | ⚫️ Пробная тёмная тема (на будущее) |
Мобильные экраны — кадры **360px** (полноэкранные ~360x800), разбросаны по секциям модулей и по
верхнему уровню `1:90`. Секции: Файлы `17:4427`/`948:65419`, Почта `97:16912`/`1609:71537`,
Конференции/Встречи `4:1166`, Конференция `1002:67493`. NB: ссылка владельца `1327:29454`
ДЕСКТОП (1440), не мобиль.
## Снятые токены (сверено с Figma)
**Цвета** — совпадают с текущим `F7Colors` (палитра снята точно ранее): Primary `#70B62B`,
Background `#FBFBFB`, Surface `#FFFFFF`, SurfaceMuted `#F5F5F5`, TextPrimary `#151515`,
TextSecondary `#808080`, Border `#E6E6E6`, PrimaryLight `#F1F8EA`.
**Типографика** — шрифт **Raleway** (medium 500 / semibold 600), шкала 14/18/20/24px
(lh 20/22/24/26). В `F7Typography` есть база; точные размеры конкретных экранов сверяются поэкранно.
**Spacing/радиусы** — раньше НЕ было токенов (аудит: ~1087 инлайновых .dp). Заведены
`F7Spacing` (4dp-сетка) + `F7Radius`. Точные значения экрана — с макета.
## Процесс попиксельной вёрстки (на экран)
1. Найти node ID мобильного экрана (360) в Figma, отрендерить PNG-эталон (scale=2).
2. Извлечь измерения: размеры/паддинги/цвета/текст-стили дочерних узлов (точечный nodes-запрос).
3. Свести к токенам (`F7Colors`/`F7Spacing`/`F7Typography`), реализовать в Compose.
4. Верификация side-by-side: PNG Figma ↔ скриншот приложения (владелец — физ.телефон + BlueStacks;
universal debug-APK со всеми ABI готов, дефолт-URL glb).
+10 -10
View File
@@ -27,15 +27,15 @@ dependencies {
implementation project(':core:auth') implementation project(':core:auth')
implementation project(':core:network') implementation project(':core:network')
implementation project(':core:designsystem') implementation project(':core:designsystem')
implementation libs.coroutines.android implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.compose.foundation implementation 'androidx.compose.foundation:foundation'
implementation libs.lifecycle.viewmodel.compose implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation libs.activity.compose implementation 'androidx.activity:activity-compose:1.10.1'
implementation libs.core.ktx implementation 'androidx.core:core-ktx:1.15.0'
implementation libs.coil.compose implementation 'io.coil-kt:coil-compose:2.7.0'
implementation libs.coil.svg implementation 'io.coil-kt:coil-svg:2.7.0'
} }
-195
View File
@@ -1,195 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:CalendarApiClient.kt$CalendarApiClient$('/')</ID>
<ID>ArgumentListWrapping:CalendarApiClient.kt$CalendarApiClient$(query.trim(), Charsets.UTF_8.name())</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$("$base/themes/forbion/images/calendar/blank-box-gray.svg", "Незапланированные задачи")</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$("$base/themes/forbion/images/calendar/calendar-edit-export-white.svg", "Синхронизация с телефоном")</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$("+${dayEvents.size - 4}", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$("\n")</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$(1.dp, if (day == selectedDay) F7Colors.Primary else F7Colors.Border, RoundedCornerShape(12.dp))</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$(12.dp)</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$(cal.accountName, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)</ID>
<ID>ArgumentListWrapping:CalendarComponents.kt$(event.location, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted, maxLines = 1)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("$count событ.", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("ru")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Весь день")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Комната Talk")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Корзина пуста", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Нет задач без срока", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(10.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(16.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(8.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(calendarHref = cal.href)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(classification = cl)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(recurrence = preset, customRrule = "")) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(status = st)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().clickable { onDraftChange(draft.copy(mode = mode)) }, verticalAlignment = Alignment.CenterVertically)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(addTalkRoom = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(allDay = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(calendarHref = cal.href)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(categories = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.allDay, onCheckedChange = { onDraftChange(draft.copy(allDay = it)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(classification = cl)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(description = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(addTalkRoom = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(allDay = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(calendarHref = cal.href))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(categories = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(classification = cl))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(description = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(endTime = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(mode = mode))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(name = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset, customRrule = ""))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(reminderMinutes = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(startTime = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(status = st))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(subscriptionUrl = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(title = it))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(endTime = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (month == YearMonth.from(selectedDay)) F7Colors.PrimaryLight else F7Colors.Surface)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru"))</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(max = 520.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(mode = mode)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(name = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = onDelete, enabled = !state.deleting)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset, customRrule = "")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(rememberScrollState())</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminderMinutes = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.classification == cl, onClick = { onDraftChange(draft.copy(classification = cl)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.recurrence == preset, onClick = { onDraftChange(draft.copy(recurrence = preset)) })</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(start = 8.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(startTime = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(status = st)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(subscriptionUrl = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(title = it)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(top = 12.dp)</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.attendeeQuery, onValueChange = onSearchAttendees, label = "Поиск участника")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.endTime, onValueChange = { onDraftChange(draft.copy(endTime = it)) }, label = "Окончание")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.name, onValueChange = { onDraftChange(draft.copy(name = it)) }, label = "Название")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.reminderMinutes.toString(), onValueChange = { v -&gt; v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.title, onValueChange = { onDraftChange(draft.copy(title = it)) }, label = "Название")</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d", Locale.ENGLISH)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d, yyyy", Locale.ENGLISH)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("dd.MM.yyyy HH:mm")</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(ZoneId.systemDefault())</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(epoch)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(session, st.selectedDay, st.selectedDay, visibleCalendarHrefs)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 1, 1)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 12, 31)</ID>
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(start)</ID>
<ID>CyclomaticComplexMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, onFocusEventConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>CyclomaticComplexMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
<ID>ForEachOnRange:CalendarComponents.kt$0..23</ID>
<ID>ForEachOnRange:CalendarComponents.kt$0..24</ID>
<ID>ImportOrdering:CalendarComponents.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.aspectRatio 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 import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.material3.AlertDialog import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.offset import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import java.time.LocalTime 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext 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.dp 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.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
<ID>ImportOrdering:CalendarRepository.kt$import okhttp3.OkHttpClient import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.CalendarAlarmData import ru.forbion.f7cloud.core.network.CalendarAttendeeData import ru.forbion.f7cloud.core.network.CalendarEventData import ru.forbion.f7cloud.core.network.CalendarIcs import ru.forbion.f7cloud.core.network.CalDavClient import ru.forbion.f7cloud.core.network.DavCalendar import ru.forbion.f7cloud.core.network.DavEvent import ru.forbion.f7cloud.core.network.DavTask import ru.forbion.f7cloud.core.network.DavTrashEvent import ru.forbion.f7cloud.core.network.NetworkFactory import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId import java.time.ZoneOffset</ID>
<ID>ImportOrdering:CalendarScreen.kt$import android.Manifest import android.content.pm.PackageManager import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.delay import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import java.time.format.DateTimeFormatter import java.util.Locale</ID>
<ID>Indentation:CalendarScreen.kt$ </ID>
<ID>LongMethod:CalendarComponents.kt$@Composable fun CalendarDayTimeline( session: AuthSession, day: LocalDate, events: List&lt;CalendarEventItem&gt;, viewMode: CalendarViewMode, onViewMode: (CalendarViewMode) -&gt; Unit, onEventClick: (CalendarEventItem) -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, onFocusEventConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
<ID>LoopWithTooManyJumpStatements:CalendarApiClient.kt$CalendarApiClient$for</ID>
<ID>LoopWithTooManyJumpStatements:CalendarSyncEngine.kt$CalendarSyncEngine$for</ID>
<ID>MaxLineLength:CalendarApiClient.kt$CalendarApiClient$val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/location?search=${java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())}"</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Column</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.reminderMinutes.toString(), onValueChange = { v -&gt; v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$RadioButton(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Row</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Комната Talk"); Switch(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$confirmButton = { F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving) }</ID>
<ID>MaxLineLength:CalendarExtendedUi.kt$if (isEdit) TextButton(onClick = onDelete, enabled = !state.deleting) { Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error) }</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$.</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -&gt; st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -&gt; st.copy(selectedDay = st.selectedDay.plusDays(1), visibleMonth = YearMonth.from(st.selectedDay.plusDays(1)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.LIST -&gt; st.copy(selectedDay = st.selectedDay.minusDays(14), visibleMonth = YearMonth.from(st.selectedDay.minusDays(14)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.LIST -&gt; st.copy(selectedDay = st.selectedDay.plusDays(14), visibleMonth = YearMonth.from(st.selectedDay.plusDays(14)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.MONTH -&gt; st.copy(visibleMonth = st.visibleMonth.minusMonths(1), selectedDay = st.visibleMonth.minusMonths(1).atDay(1))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.MONTH -&gt; st.copy(visibleMonth = st.visibleMonth.plusMonths(1), selectedDay = st.visibleMonth.plusMonths(1).atDay(1))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -&gt; st.copy(selectedDay = st.selectedDay.minusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.minusWeeks(1)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -&gt; st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.YEAR -&gt; repository.loadRange(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(deleting = false, createDialogOpen = false, eventDetail = null, focusedEventUid = null, snackMessage = "Удалено")</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), eventDetail = event, focusedEventUid = event.uid)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year, datePickerOpen = false)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$private fun formatLastSync(epoch: Long)</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$return if (visible.isEmpty()) _state.value.events else _state.value.events.filter { it.calendarHref in visible || it.calendarHref.isBlank() }</ID>
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$snackMessage = if (showSnack) "Синхронизация завершена (${result.pushedToDevice + result.pushedToServer + result.updated + result.linked})" else _state.value.snackMessage</ID>
<ID>MaximumLineLength:CalendarApiClient.kt$CalendarApiClient$ </ID>
<ID>MaximumLineLength:CalendarComponents.kt$ </ID>
<ID>MaximumLineLength:CalendarExtendedUi.kt$ </ID>
<ID>MaximumLineLength:CalendarRepository.kt$CalendarRepository$ </ID>
<ID>MaximumLineLength:CalendarViewModel.kt$CalendarViewModel$ </ID>
<ID>NoMultipleSpaces:CalendarSyncEngine.kt$CalendarSyncEngine.Companion$ </ID>
<ID>NoUnusedImports:CalendarApiClient.kt$ru.forbion.f7cloud.feature.calendar.CalendarApiClient.kt</ID>
<ID>NoUnusedImports:CalendarComponents.kt$ru.forbion.f7cloud.feature.calendar.CalendarComponents.kt</ID>
<ID>NoUnusedImports:CalendarExtendedUi.kt$ru.forbion.f7cloud.feature.calendar.CalendarExtendedUi.kt</ID>
<ID>NoUnusedImports:CalendarModels.kt$ru.forbion.f7cloud.feature.calendar.CalendarModels.kt</ID>
<ID>NoUnusedImports:CalendarScreen.kt$ru.forbion.f7cloud.feature.calendar.CalendarScreen.kt</ID>
<ID>NoUnusedImports:CalendarSyncEngine.kt$ru.forbion.f7cloud.feature.calendar.CalendarSyncEngine.kt</ID>
<ID>NoUnusedImports:DeviceCalendarClient.kt$ru.forbion.f7cloud.feature.calendar.DeviceCalendarClient.kt</ID>
<ID>ParameterListWrapping:CalendarViewModel.kt$CalendarViewModel$(draft: CalendarCreateBookDraft)</ID>
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarIconButton( iconUrl: String, contentDescription: String?, onClick: () -&gt; Unit, )</ID>
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarNavButton(text: String, onClick: () -&gt; Unit)</ID>
<ID>UnusedPrivateProperty:CalendarComponents.kt$private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)</ID>
<ID>UnusedPrivateProperty:CalendarSyncEngine.kt$CalendarSyncEngine$val mappedDevIds2 = mappings.map { it.deviceEventId }.toSet()</ID>
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Календари не найдены")</ID>
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Не удалось загрузить события календаря: $lastError")</ID>
<ID>Wrapping:CalendarExtendedUi.kt$;</ID>
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onAddAttendee(s)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onApplyLocation(s)</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(addTalkRoom = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(calendarHref = cal.href))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(categories = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(classification = cl))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(description = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(mode = mode))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(recurrence = preset, customRrule = ""))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(reminderMinutes = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(startTime = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(subscriptionUrl = it))</ID>
<ID>Wrapping:CalendarExtendedUi.kt$s.name</ID>
<ID>Wrapping:CalendarRepository.kt$CalendarRepository$;</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$;</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(loading = false, error = it.message, unauthorized = it is UnauthorizedException)</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.calendarHref in visible || it.calendarHref.isBlank()</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.icsValue == event.classification</ID>
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel${ _state.value = _state.value.copy(createBookOpen = false, createBookDraft = CalendarCreateBookDraft()) }</ID>
</CurrentIssues>
</SmellBaseline>
@@ -46,6 +46,7 @@ class CalendarApiClient {
.applyOcsJson() .applyOcsJson()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete attendee") val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete attendee")
val data = json.optJSONArray("data") ?: json.optJSONObject("ocs")?.optJSONArray("data") ?: return emptyList() val data = json.optJSONArray("data") ?: json.optJSONObject("ocs")?.optJSONArray("data") ?: return emptyList()
return buildList { return buildList {
@@ -76,6 +77,7 @@ class CalendarApiClient {
.applyOcsJson() .applyOcsJson()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete location") val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete location")
val data = json.optJSONArray("data") ?: return emptyList() val data = json.optJSONArray("data") ?: return emptyList()
return buildList { return buildList {
@@ -102,6 +104,7 @@ class CalendarApiClient {
.applyOcsJson() .applyOcsJson()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Calendar config failed HTTP ${response.code}") if (!response.isSuccessful) error("Calendar config failed HTTP ${response.code}")
} }
} }
@@ -121,6 +124,7 @@ class CalendarApiClient {
.applyOcsJson() .applyOcsJson()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room") val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
val meta = json.ocsMeta() val meta = json.ocsMeta()
if (!isOcsSuccess(meta)) error("Не удалось создать комнату Talk") if (!isOcsSuccess(meta)) error("Не удалось создать комнату Talk")
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -387,55 +388,46 @@ private fun CalendarIconButton(
fun CalendarMonthGrid( fun CalendarMonthGrid(
month: YearMonth, month: YearMonth,
selectedDay: LocalDate, selectedDay: LocalDate,
daysWithEvents: Set<LocalDate>,
eventsByDay: Map<LocalDate, List<CalendarEventItem>>, eventsByDay: Map<LocalDate, List<CalendarEventItem>>,
onSelectDay: (LocalDate) -> Unit, onSelectDay: (LocalDate) -> Unit,
) { ) {
val days = CalendarRepository.monthGridDays(month) val days = CalendarRepository.monthGridDays(month)
val today = LocalDate.now() val today = LocalDate.now()
Column( Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Заголовок дней недели — серые пилюли (по макету «Календарь · Месяц»)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
weekDayLabels.forEach { label ->
Box(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.clip(RoundedCornerShape(100.dp)) .clip(RoundedCornerShape(12.dp))
.background(F7Colors.Grey2) .background(F7Colors.Surface)
.padding(vertical = 6.dp), .border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
contentAlignment = Alignment.Center, .padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Row(modifier = Modifier.fillMaxWidth()) {
weekDayLabels.forEach { label ->
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium, textAlign = TextAlign.Center,
color = F7Colors.TextSecondary, style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextMuted,
) )
} }
} }
}
// Гридлайн-сетка без скруглений (по макету)
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, F7Colors.Grey3),
) {
days.chunked(7).forEach { week -> days.chunked(7).forEach { week ->
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
week.forEach { day -> week.forEach { day ->
val inMonth = day.month == month.month val inMonth = day.month == month.month
val selected = day == selectedDay
val hasEvents = daysWithEvents.contains(day)
val dayEvents = eventsByDay[day].orEmpty() val dayEvents = eventsByDay[day].orEmpty()
CalendarDayCell( CalendarDayCell(
day = day.dayOfMonth, day = day.dayOfMonth,
inMonth = inMonth, inMonth = inMonth,
isToday = day == today, isToday = day == today,
selected = day == selectedDay, selected = selected,
events = dayEvents, hasEvents = hasEvents,
preview = dayEvents.firstOrNull()?.summary.orEmpty(),
onClick = { onSelectDay(day) }, onClick = { onSelectDay(day) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -444,7 +436,6 @@ fun CalendarMonthGrid(
} }
} }
} }
}
@Composable @Composable
private fun CalendarDayCell( private fun CalendarDayCell(
@@ -452,72 +443,63 @@ private fun CalendarDayCell(
inMonth: Boolean, inMonth: Boolean,
isToday: Boolean, isToday: Boolean,
selected: Boolean, selected: Boolean,
events: List<CalendarEventItem>, hasEvents: Boolean,
preview: String,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val cellBg = when { val bg = when {
isToday -> F7Colors.Green10
selected -> F7Colors.PrimaryLight selected -> F7Colors.PrimaryLight
isToday -> F7Colors.PrimaryLight.copy(alpha = 0.55f)
else -> F7Colors.Surface else -> F7Colors.Surface
} }
Column( val borderColor = when {
modifier = modifier selected -> F7Colors.Primary
.heightIn(min = 66.dp) isToday -> F7Colors.PrimaryDark
.border(0.5.dp, F7Colors.Grey3) else -> F7Colors.BorderLight
.background(cellBg) }
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// Номер дня сверху-слева; сегодня — в зелёном круге
Box( Box(
modifier = Modifier modifier = modifier
.size(22.dp) .aspectRatio(1f)
.then(if (isToday) Modifier.clip(CircleShape).background(F7Colors.Primary) else Modifier), .padding(2.dp)
contentAlignment = Alignment.Center, .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(
text = day.toString(), text = day.toString(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontWeight = if (isToday) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
color = when { color = when {
isToday -> F7Colors.TextOnPrimary
!inMonth -> F7Colors.TextMuted !inMonth -> F7Colors.TextMuted
selected -> F7Colors.PrimaryDark
else -> F7Colors.TextPrimary else -> F7Colors.TextPrimary
}, },
) )
} if (hasEvents && inMonth && preview.isNotBlank()) {
if (inMonth) {
events.take(2).forEach { event ->
CalendarDayEventPill(event.summary.ifBlank { "Событие" })
}
if (events.size > 2) {
Text( Text(
"+${events.size - 2}", preview,
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.Medium,
)
}
}
}
}
@Composable
private fun CalendarDayEventPill(text: String) {
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.PrimaryLight)
.padding(horizontal = 4.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = F7Colors.PrimaryDark, color = F7Colors.PrimaryDark,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 1.dp),
) )
} else if (hasEvents && inMonth) {
Box(
modifier = Modifier
.padding(top = 2.dp)
.size(5.dp)
.clip(CircleShape)
.background(F7Colors.Primary),
)
}
}
}
} }
@Composable @Composable
@@ -343,6 +343,7 @@ fun CalendarScreen(
CalendarMonthGrid( CalendarMonthGrid(
month = state.visibleMonth, month = state.visibleMonth,
selectedDay = state.selectedDay, selectedDay = state.selectedDay,
daysWithEvents = vm.daysWithEvents(),
eventsByDay = eventsByDay, eventsByDay = eventsByDay,
onSelectDay = vm::selectDay, onSelectDay = vm::selectDay,
) )
+8 -8
View File
@@ -28,13 +28,13 @@ dependencies {
implementation project(':core:network') implementation project(':core:network')
implementation project(':core:database') implementation project(':core:database')
implementation project(':core:designsystem') implementation project(':core:designsystem')
implementation libs.coroutines.android implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.compose.foundation implementation 'androidx.compose.foundation:foundation'
implementation libs.lifecycle.viewmodel.compose implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation libs.coil.compose implementation 'io.coil-kt:coil-compose:2.7.0'
implementation libs.coil.svg implementation 'io.coil-kt:coil-svg:2.7.0'
} }
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force &amp;&amp; !needsPhotoBackfill &amp;&amp; !needsDetailsBackfill &amp;&amp; System.currentTimeMillis() - lastSync &lt; SYNC_INTERVAL_MS</ID>
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -&gt; Unit, )</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$"https://$it"</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
</CurrentIssues>
</SmellBaseline>
@@ -48,7 +48,6 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors 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.F7ModuleScreen
@Composable @Composable
@@ -76,24 +75,12 @@ fun ContactsScreen(
modifier = modifier, modifier = modifier,
loading = state.loading && state.contacts.isEmpty(), loading = state.loading && state.contacts.isEmpty(),
error = state.error, error = state.error,
) {
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
// панели, теперь — из шапки, панель стала навигационной)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
ContactsSearchBar( ContactsSearchBar(
serverUrl = session.serverUrl, serverUrl = session.serverUrl,
query = state.searchQuery, query = state.searchQuery,
onQueryChange = vm::setSearchQuery, onQueryChange = vm::setSearchQuery,
modifier = Modifier.weight(1f),
) )
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
}
if (state.syncing && state.contacts.isNotEmpty()) { if (state.syncing && state.contacts.isNotEmpty()) {
Text( Text(
"Обновление…", "Обновление…",
@@ -310,11 +297,12 @@ private fun ContactsSearchBar(
serverUrl: String, serverUrl: String,
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
val base = serverUrl.trimEnd('/') val base = serverUrl.trimEnd('/')
Box( Box(
modifier = modifier modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.height(40.dp) .height(40.dp)
.shadow(2.dp, RoundedCornerShape(100.dp), spotColor = Color(0xFFCBCBCB)) .shadow(2.dp, RoundedCornerShape(100.dp), spotColor = Color(0xFFCBCBCB))
.clip(RoundedCornerShape(100.dp)) .clip(RoundedCornerShape(100.dp))
+7 -7
View File
@@ -27,12 +27,12 @@ dependencies {
implementation project(':core:auth') implementation project(':core:auth')
implementation project(':core:network') implementation project(':core:network')
implementation project(':core:designsystem') implementation project(':core:designsystem')
implementation libs.coroutines.android implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation libs.json implementation 'org.json:json:20240303'
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.compose.foundation implementation 'androidx.compose.foundation:foundation'
implementation libs.lifecycle.viewmodel.compose implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
} }
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NoUnusedImports:DeckRepository.kt$ru.forbion.f7cloud.feature.deck.DeckRepository.kt</ID>
</CurrentIssues>
</SmellBaseline>
@@ -79,6 +79,7 @@ class DeckRepository {
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any { private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Deck API HTTP ${response.code}") error("Deck API HTTP ${response.code}")
} }
+10 -10
View File
@@ -27,15 +27,15 @@ dependencies {
implementation project(':core:auth') implementation project(':core:auth')
implementation project(':core:network') implementation project(':core:network')
implementation project(':core:designsystem') implementation project(':core:designsystem')
implementation libs.coroutines.android implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation libs.json implementation 'org.json:json:20240303'
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.compose.foundation implementation 'androidx.compose.foundation:foundation'
implementation libs.lifecycle.viewmodel.compose implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation libs.activity.compose implementation 'androidx.activity:activity-compose:1.10.1'
implementation libs.coil.compose implementation 'io.coil-kt:coil-compose:2.7.0'
implementation libs.coil.svg implementation 'io.coil-kt:coil-svg:2.7.0'
} }
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:SupportRepository.kt$SupportRepository$config.isSupportAdmin &amp;&amp; r == "support" &amp;&amp; u.isNotEmpty() &amp;&amp; a == u</ID>
<ID>ComplexCondition:SupportScreen.kt$state.loading &amp;&amp; state.tickets.isNotEmpty() &amp;&amp; state.selectedTicket == null &amp;&amp; !state.showCreate</ID>
<ID>ImportOrdering:SupportComponents.kt$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.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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState 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.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale 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.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.Surface import androidx.compose.ui.graphics.Brush import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
<ID>LongMethod:SupportScreen.kt$@Composable fun SupportScreen( session: AuthSession, modifier: Modifier = Modifier, createRequest: Int = 0, openTicketNumber: String? = null, onOpenTicketConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>LoopWithTooManyJumpStatements:SupportRepository.kt$SupportRepository$for</ID>
<ID>LoopWithTooManyJumpStatements:SupportViewModel.kt$SupportViewModel$while</ID>
<ID>MaximumLineLength:SupportComponents.kt$ </ID>
<ID>NoUnusedImports:SupportRepository.kt$ru.forbion.f7cloud.feature.f7support.SupportRepository.kt</ID>
<ID>UnusedParameter:SupportRepository.kt$SupportRepository$config: SupportConfig</ID>
<ID>UnusedParameter:SupportViewModel.kt$SupportViewModel$session: AuthSession</ID>
</CurrentIssues>
</SmellBaseline>
@@ -26,6 +26,7 @@ class SupportRepository {
.applyOcsJson() .applyOcsJson()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Support config HTTP ${response.code}") error("Support config HTTP ${response.code}")
} }
@@ -265,6 +266,7 @@ class SupportRepository {
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType())) .post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val body = response.body?.string().orEmpty() val body = response.body?.string().orEmpty()
val err = runCatching { val err = runCatching {
+12 -13
View File
@@ -29,18 +29,17 @@ dependencies {
implementation project(':core:network') implementation project(':core:network')
implementation project(':core:designsystem') implementation project(':core:designsystem')
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.compose.material.icons.extended implementation 'androidx.compose.foundation:foundation'
implementation libs.compose.foundation implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation libs.lifecycle.viewmodel.compose implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation libs.coroutines.android implementation 'org.json:json:20240303'
implementation libs.json implementation 'androidx.core:core-ktx:1.13.1'
implementation libs.core.ktx implementation 'androidx.activity:activity-compose:1.9.0'
implementation libs.activity.compose implementation 'androidx.documentfile:documentfile:1.0.1'
implementation libs.documentfile implementation 'io.coil-kt:coil-compose:2.6.0'
implementation libs.coil.compose implementation 'io.coil-kt:coil-svg:2.6.0'
implementation libs.coil.svg
} }
-34
View File
@@ -1,34 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:FilesScreen.kt$("/", " ")</ID>
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая диаграмма", ".odg", openAfterCreate = true)</ID>
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая доска", ".whiteboard", openAfterCreate = false)</ID>
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая презентация", ".pptx", openAfterCreate = true)</ID>
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новая таблица", ".xlsx", openAfterCreate = true)</ID>
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новый документ", ".docx", openAfterCreate = true)</ID>
<ID>ArgumentListWrapping:FilesViewModel.kt$FilesViewModel$(session, "Новый текстовый файл", ".txt", openAfterCreate = false)</ID>
<ID>CyclomaticComplexMethod:FilesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilesScreen( session: AuthSession, modifier: Modifier = Modifier, uploadRequest: Int = 0, pushRefreshRequest: Int = 0, openFileId: Long? = null, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, settingsOpen: Boolean = false, onSettingsOpenChange: (Boolean) -&gt; Unit = {}, onOpenFileConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, onOpenOfficeEditor: (OfficeEditorLaunch) -&gt; Unit = {}, )</ID>
<ID>ImportOrdering:FilesComponents.kt$import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally 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.Row import androidx.compose.foundation.layout.Spacer 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.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.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.foundation.text.BasicTextField import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow 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.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInWindow 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.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import ru.forbion.f7cloud.core.designsystem.F7AlertDialog 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.text.SimpleDateFormat import java.util.Date import java.util.Locale</ID>
<ID>ImportOrdering:FilesRepository.kt$import android.content.Context import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.database.F7Database import ru.forbion.f7cloud.core.database.FileEntity import ru.forbion.f7cloud.core.network.DavClient import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.UnauthorizedException import ru.forbion.f7cloud.core.network.davFileUrl import ru.forbion.f7cloud.core.network.davFolderUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody</ID>
<ID>ImportOrdering:FilesScreen.kt$import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7AlertDialog import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import java.io.File</ID>
<ID>ImportOrdering:ImageViewerActivity.kt$import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import ru.forbion.f7cloud.core.designsystem.F7Theme import java.io.File</ID>
<ID>LargeClass:FilesViewModel.kt$FilesViewModel : ViewModel</ID>
<ID>LongMethod:FilesComponents.kt$@Composable fun FilesNavigationSidebar( serverUrl: String, visible: Boolean, browseMode: FilesBrowseMode, storageStats: FilesStorageStats, folderTree: List&lt;FilesFolderTreeNode&gt;, expandedTreePaths: Set&lt;String&gt;, expandedSections: Set&lt;String&gt;, onDismiss: () -&gt; Unit, onBrowseModeClick: (FilesBrowseMode) -&gt; Unit, onFolderPathClick: (String) -&gt; Unit, onToggleTreePath: (String) -&gt; Unit, onToggleSection: (String) -&gt; Unit, onWebOnlyClick: (String) -&gt; Unit, )</ID>
<ID>LongMethod:FilesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilesScreen( session: AuthSession, modifier: Modifier = Modifier, uploadRequest: Int = 0, pushRefreshRequest: Int = 0, openFileId: Long? = null, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, settingsOpen: Boolean = false, onSettingsOpenChange: (Boolean) -&gt; Unit = {}, onOpenFileConsumed: () -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, onOpenOfficeEditor: (OfficeEditorLaunch) -&gt; Unit = {}, )</ID>
<ID>MatchingDeclarationName:FilesCreateMenu.kt$FilesCreateAction</ID>
<ID>MaximumLineLength:FilesScreen.kt$ </ID>
<ID>MaximumLineLength:FilesViewModel.kt$FilesViewModel$ </ID>
<ID>NoUnusedImports:FileDownloadRepository.kt$ru.forbion.f7cloud.feature.files.FileDownloadRepository.kt</ID>
<ID>NoUnusedImports:FilesApiRepository.kt$ru.forbion.f7cloud.feature.files.FilesApiRepository.kt</ID>
<ID>NoUnusedImports:FilesComponents.kt$ru.forbion.f7cloud.feature.files.FilesComponents.kt</ID>
<ID>NoUnusedImports:FilesRepository.kt$ru.forbion.f7cloud.feature.files.FilesRepository.kt</ID>
<ID>NoUnusedImports:FilesScreen.kt$ru.forbion.f7cloud.feature.files.FilesScreen.kt</ID>
<ID>NoUnusedImports:FilesTemplatesRepository.kt$ru.forbion.f7cloud.feature.files.FilesTemplatesRepository.kt</ID>
<ID>NoUnusedImports:RichdocumentsRepository.kt$ru.forbion.f7cloud.feature.files.RichdocumentsRepository.kt</ID>
<ID>UnusedParameter:FilesComponents.kt$selectionMode: Boolean</ID>
<ID>UnusedParameter:FilesViewModel.kt$FilesViewModel$context: Context</ID>
<ID>Wrapping:FilesCreateMenu.kt$;</ID>
</CurrentIssues>
</SmellBaseline>
@@ -1,16 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2581)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M14.7812 15.2266L14.7812 25.7266" stroke="#70B62B" stroke-width="0.88" stroke-linecap="round"/>
<path d="M10.5 9V17V25C10.5 25.5523 10.9477 26 11.5 26H12.6408H14.7817H17.0863H19.3908H24H24.5C25.0523 26 25.5 25.5523 25.5 25V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9Z" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M13 17H16.5" stroke="#70B62B" stroke-width="0.88" stroke-linecap="round"/>
<path d="M13 19.9961H16.5" stroke="#70B62B" stroke-width="0.88" stroke-linecap="round"/>
<path d="M13 23H16.5" stroke="#70B62B" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
</g>
<defs>
<clipPath id="clip0_4_2581">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,14 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2574)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M10.5 17V9C10.5 8.44772 10.9477 8 11.5 8H19.5858C19.851 8 20.1054 8.10536 20.2929 8.29289L25.2071 13.2071C25.3946 13.3946 25.5 13.649 25.5 13.9142V25C25.5 25.5523 25.0523 26 24.5 26H24" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M17.6641 19.0586C17.6641 19.0586 19.4443 19.8498 19.4443 21.8349C19.4443 23.82 17.6641 24.6419 17.6641 24.6419" stroke="#70B62B" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M10.5693 23.7004V20.0273C10.5693 19.972 10.6141 19.9273 10.6693 19.9273L12.5109 19.9273C12.5365 19.9273 12.5612 19.9174 12.5798 19.8998L14.9942 17.6084C15.0579 17.548 15.1631 17.5931 15.1631 17.681L15.1631 21.6387L15.163 26.0238C15.163 26.1114 15.0584 26.1566 14.9946 26.0966L12.5797 23.8276C12.5611 23.8101 12.5367 23.8005 12.5112 23.8005L10.6693 23.8004C10.6141 23.8004 10.5693 23.7557 10.5693 23.7004Z" stroke="#70B62B" stroke-width="0.88" stroke-linecap="square"/>
</g>
<defs>
<clipPath id="clip0_4_2574">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,14 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2622)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M10.5 17V9C10.5 8.44772 10.9477 8 11.5 8H19.5858C19.851 8 20.1054 8.10536 20.2929 8.29289L25.2071 13.2071C25.3946 13.3946 25.5 13.649 25.5 13.9142V25C25.5 25.5523 25.0523 26 24.5 26H17.5859" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<circle cx="12.5363" cy="21.7394" r="2.78371" stroke="#70B62B" stroke-width="0.958224"/>
<path d="M10.7344 24.1016V27.7147C10.7344 27.7603 10.7873 27.7856 10.8228 27.7569L12.5031 26.4015C12.523 26.3854 12.5514 26.3854 12.5713 26.4015L14.2515 27.7569C14.2871 27.7856 14.34 27.7603 14.34 27.7147V24.1016" stroke="#70B62B" stroke-width="0.88" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_4_2622">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,13 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2608)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M10.5 9V17V25C10.5 25.5523 10.9477 26 11.5 26H24H24.5C25.0523 26 25.5 25.5523 25.5 25V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9Z" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M22.897 19.429C23.0023 19.5345 23.0615 19.6775 23.0615 19.8265C23.0615 19.9756 23.0023 20.1186 22.897 20.224L20.647 22.474C20.5404 22.5734 20.3993 22.6275 20.2536 22.6249C20.1079 22.6223 19.9688 22.5633 19.8658 22.4602C19.7627 22.3572 19.7037 22.2181 19.7011 22.0724C19.6986 21.9267 19.7526 21.7857 19.852 21.679L21.7036 19.8265L19.852 17.974C19.7967 17.9225 19.7524 17.8604 19.7217 17.7914C19.6909 17.7224 19.6744 17.6479 19.6731 17.5724C19.6717 17.4969 19.6856 17.4219 19.7139 17.3518C19.7422 17.2818 19.7843 17.2182 19.8377 17.1648C19.8911 17.1113 19.9548 17.0692 20.0248 17.0409C20.0948 17.0126 20.1699 16.9988 20.2454 17.0001C20.3209 17.0014 20.3954 17.018 20.4644 17.0487C20.5334 17.0794 20.5955 17.1238 20.647 17.179L22.897 19.429ZM16.147 17.179C16.0415 17.0737 15.8986 17.0145 15.7495 17.0145C15.6004 17.0145 15.4575 17.0737 15.352 17.179L13.102 19.429C12.9967 19.5345 12.9375 19.6775 12.9375 19.8265C12.9375 19.9756 12.9967 20.1186 13.102 20.224L15.352 22.474C15.4035 22.5293 15.4656 22.5736 15.5346 22.6044C15.6036 22.6351 15.6781 22.6516 15.7536 22.653C15.8291 22.6543 15.9042 22.6404 15.9742 22.6121C16.0442 22.5838 16.1079 22.5417 16.1613 22.4883C16.2147 22.4349 16.2568 22.3713 16.2851 22.3012C16.3134 22.2312 16.3273 22.1562 16.3259 22.0806C16.3246 22.0051 16.3081 21.9306 16.2773 21.8616C16.2466 21.7926 16.2023 21.7305 16.147 21.679L14.2954 19.8265L16.147 17.974C16.2523 17.8686 16.3115 17.7256 16.3115 17.5765C16.3115 17.4275 16.2523 17.2845 16.147 17.179Z" fill="#70B62B"/>
</g>
<defs>
<clipPath id="clip0_4_2608">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,15 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2614)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M25.5 18.8366V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9V17V18.8366" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M23.6133 21.1094V25.9015C23.6133 25.9568 23.6581 26.0015 23.7133 26.0015H26.5154" stroke="#70B62B" stroke-width="1.05" stroke-linecap="round"/>
<path d="M13.4842 26.0033H10.682C10.6268 26.0033 10.582 25.9585 10.582 25.9033L10.582 23.5572L10.582 21.2172C10.582 21.162 10.6268 21.1172 10.682 21.1172L13.4842 21.1173M13.4842 23.5572H10.582" stroke="#70B62B" stroke-width="1.05" stroke-linecap="round"/>
<path d="M20.945 25.999L20.9453 21.2171C20.9453 21.1185 20.8177 21.0794 20.7625 21.161L18.6081 24.347C18.5684 24.4057 18.4821 24.4057 18.4424 24.347L16.288 21.161C16.2328 21.0793 16.1052 21.1184 16.1052 21.217L16.1052 25.999" stroke="#70B62B" stroke-width="1.05" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_4_2614">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,13 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2590)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M10.5 9V17V25C10.5 25.5523 10.9477 26 11.5 26H24H24.5C25.0523 26 25.5 25.5523 25.5 25V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9Z" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H22.7079H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M12.5039 20.5874V23.9547C12.5039 24.0099 12.5487 24.0547 12.6039 24.0547H16.1811" stroke="#70B62B" stroke-width="0.88" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_4_2590">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,18 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2561)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<g clip-path="url(#clip1_4_2561)">
<path d="M10.5 17V9C10.5 8.44772 10.9477 8 11.5 8H19.5858C19.851 8 20.1054 8.10536 20.2929 8.29289L25.2071 13.2071C25.3946 13.3946 25.5 13.649 25.5 13.9142V25C25.5 25.5523 25.0523 26 24.5 26H24" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M8.16592 26.0648L11.5491 20.8775C11.5893 20.8159 11.6799 20.8173 11.7182 20.8801L13.057 23.0741C13.0954 23.1371 13.1866 23.1382 13.2265 23.076L15.6083 19.3668C15.6469 19.3067 15.7343 19.3053 15.7748 19.3641L20.3875 26.0627C20.4332 26.129 20.3857 26.2194 20.3052 26.2194L8.24968 26.2194C8.17026 26.2194 8.12254 26.1313 8.16592 26.0648Z" stroke="#70B62B" stroke-width="0.88" stroke-linecap="square"/>
</g>
</g>
<defs>
<clipPath id="clip0_4_2561">
<rect width="35" height="35" fill="white"/>
</clipPath>
<clipPath id="clip1_4_2561">
<rect width="24" height="24" fill="white" transform="translate(6 5)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,13 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2596)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M25.5 18.8366V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9V17V18.8366" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M26.2255 21.1092C26.2255 21.248 26.1704 21.3811 26.0723 21.4792C25.9741 21.5774 25.841 21.6325 25.7022 21.6325H23.4347V23.3767H25.0045C25.1433 23.3767 25.2764 23.4319 25.3746 23.53C25.4727 23.6281 25.5278 23.7612 25.5278 23.9C25.5278 24.0388 25.4727 24.1719 25.3746 24.27C25.2764 24.3681 25.1433 24.4233 25.0045 24.4233H23.4347V25.9931C23.4347 26.1319 23.3796 26.265 23.2815 26.3631C23.1833 26.4612 23.0502 26.5164 22.9115 26.5164C22.7727 26.5164 22.6396 26.4612 22.5414 26.3631C22.4433 26.265 22.3882 26.1319 22.3882 25.9931V21.1092C22.3882 20.9704 22.4433 20.8373 22.5414 20.7392C22.6396 20.6411 22.7727 20.5859 22.9115 20.5859H25.7022C25.841 20.5859 25.9741 20.6411 26.0723 20.7392C26.1704 20.8373 26.2255 20.9704 26.2255 21.1092ZM14.7135 22.8535C14.7135 23.4548 14.4746 24.0316 14.0494 24.4568C13.6241 24.8821 13.0474 25.121 12.446 25.121H11.5739V25.9931C11.5739 26.1319 11.5188 26.265 11.4206 26.3631C11.3225 26.4612 11.1894 26.5164 11.0506 26.5164C10.9118 26.5164 10.7787 26.4612 10.6806 26.3631C10.5825 26.265 10.5273 26.1319 10.5273 25.9931V21.1092C10.5273 20.9704 10.5825 20.8373 10.6806 20.7392C10.7787 20.6411 10.9118 20.5859 11.0506 20.5859H12.446C13.0474 20.5859 13.6241 20.8248 14.0494 21.2501C14.4746 21.6753 14.7135 22.2521 14.7135 22.8535ZM13.667 22.8535C13.667 22.5296 13.5383 22.2191 13.3094 21.9901C13.0804 21.7611 12.7698 21.6325 12.446 21.6325H11.5739V24.0744H12.446C12.7698 24.0744 13.0804 23.9458 13.3094 23.7168C13.5383 23.4878 13.667 23.1773 13.667 22.8535ZM20.9928 23.5511C20.9928 24.3376 20.6804 25.0918 20.1243 25.6479C19.5682 26.204 18.814 26.5164 18.0276 26.5164H16.6322C16.4934 26.5164 16.3603 26.4612 16.2622 26.3631C16.164 26.265 16.1089 26.1319 16.1089 25.9931V21.1092C16.1089 20.9704 16.164 20.8373 16.2622 20.7392C16.3603 20.6411 16.4934 20.5859 16.6322 20.5859H18.0276C18.814 20.5859 19.5682 20.8983 20.1243 21.4544C20.6804 22.0105 20.9928 22.7647 20.9928 23.5511ZM19.9462 23.5511C19.9462 23.0423 19.7441 22.5543 19.3843 22.1944C19.0245 21.8346 18.5364 21.6325 18.0276 21.6325H17.1555V25.4698H18.0276C18.5364 25.4698 19.0245 25.2677 19.3843 24.9078C19.7441 24.548 19.9462 24.06 19.9462 23.5511Z" fill="#70B62B"/>
</g>
<defs>
<clipPath id="clip0_4_2596">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

@@ -1,13 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2641)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M25.5 18.8366V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9V17V18.8366" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M26.2203 21.109C26.2203 21.2478 26.1652 21.3808 26.0671 21.4789C25.969 21.577 25.836 21.6321 25.6972 21.6321H24.4767V25.9913C24.4767 26.13 24.4216 26.2631 24.3235 26.3612C24.2254 26.4593 24.0923 26.5144 23.9536 26.5144C23.8148 26.5144 23.6818 26.4593 23.5837 26.3612C23.4856 26.2631 23.4305 26.13 23.4305 25.9913V21.6321H22.2099C22.0712 21.6321 21.9381 21.577 21.84 21.4789C21.7419 21.3808 21.6868 21.2478 21.6868 21.109C21.6868 20.9703 21.7419 20.8372 21.84 20.7391C21.9381 20.641 22.0712 20.5859 22.2099 20.5859H25.6972C25.836 20.5859 25.969 20.641 26.0671 20.7391C26.1652 20.8372 26.2203 20.9703 26.2203 21.109ZM14.7121 22.8527C14.7121 23.4539 14.4733 24.0305 14.0482 24.4556C13.6231 24.8807 13.0466 25.1195 12.4454 25.1195H11.5735V25.9913C11.5735 26.13 11.5184 26.2631 11.4203 26.3612C11.3222 26.4593 11.1892 26.5144 11.0504 26.5144C10.9117 26.5144 10.7787 26.4593 10.6806 26.3612C10.5825 26.2631 10.5273 26.13 10.5273 25.9913V21.109C10.5273 20.9703 10.5825 20.8372 10.6806 20.7391C10.7787 20.641 10.9117 20.5859 11.0504 20.5859H12.4454C13.0466 20.5859 13.6231 20.8248 14.0482 21.2499C14.4733 21.675 14.7121 22.2515 14.7121 22.8527ZM13.6659 22.8527C13.6659 22.529 13.5373 22.2185 13.3084 21.9896C13.0795 21.7607 12.7691 21.6321 12.4454 21.6321H11.5735V24.0733H12.4454C12.7691 24.0733 13.0795 23.9447 13.3084 23.7158C13.5373 23.4869 13.6659 23.1764 13.6659 22.8527ZM20.6406 22.8527C20.6406 23.4539 20.4018 24.0305 19.9767 24.4556C19.5516 24.8807 18.975 25.1195 18.3738 25.1195H17.502V25.9913C17.502 26.13 17.4469 26.2631 17.3488 26.3612C17.2507 26.4593 17.1176 26.5144 16.9789 26.5144C16.8402 26.5144 16.7071 26.4593 16.609 26.3612C16.5109 26.2631 16.4558 26.13 16.4558 25.9913V21.109C16.4558 20.9703 16.5109 20.8372 16.609 20.7391C16.7071 20.641 16.8402 20.5859 16.9789 20.5859H18.3738C18.975 20.5859 19.5516 20.8248 19.9767 21.2499C20.4018 21.675 20.6406 22.2515 20.6406 22.8527ZM19.5944 22.8527C19.5944 22.529 19.4658 22.2185 19.2369 21.9896C19.008 21.7607 18.6976 21.6321 18.3738 21.6321H17.502V24.0733H18.3738C18.6976 24.0733 19.008 23.9447 19.2369 23.7158C19.4658 23.4869 19.5944 23.1764 19.5944 22.8527Z" fill="#70B62B"/>
</g>
<defs>
<clipPath id="clip0_4_2641">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,13 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2629)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69163 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M10.5 9V17V25C10.5 25.5523 10.9477 26 11.5 26H24H24.5C25.0523 26 25.5 25.5523 25.5 25V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9Z" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H22.7079H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M13.4609 15.3076H22.542C22.8513 15.3076 23.1025 15.5579 23.1025 15.8672V23.333C23.1025 23.6423 22.8513 23.8935 22.542 23.8936H13.4609C13.1517 23.8936 12.9014 23.6423 12.9014 23.333V15.8672C12.9014 15.558 13.1517 15.3077 13.4609 15.3076Z" stroke="#70B62B" stroke-width="0.88"/>
</g>
<defs>
<clipPath id="clip0_4_2629">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,13 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2602)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M25.5 18.8366V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9V17V18.8366" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M20.1708 21.4101L18.6438 23.5484L20.1708 25.6859C20.2109 25.7418 20.2395 25.805 20.2552 25.872C20.2708 25.939 20.2731 26.0084 20.262 26.0762C20.2508 26.1441 20.2264 26.2091 20.1901 26.2675C20.1538 26.3259 20.1063 26.3766 20.0504 26.4167C19.9945 26.4568 19.9313 26.4855 19.8643 26.5011C19.7974 26.5168 19.728 26.5191 19.6601 26.5079C19.5922 26.4967 19.5272 26.4723 19.4688 26.436C19.4104 26.3997 19.3597 26.3522 19.3196 26.2964L18.001 24.4484L16.6825 26.2946C16.6015 26.4075 16.479 26.4836 16.342 26.5061C16.2049 26.5287 16.0645 26.4959 15.9517 26.415C15.8388 26.334 15.7627 26.2115 15.7401 26.0745C15.7176 25.9374 15.7504 25.797 15.8313 25.6842L17.3583 23.5484L15.8313 21.411C15.7912 21.3551 15.7626 21.2919 15.7469 21.2249C15.7313 21.1579 15.729 21.0885 15.7401 21.0206C15.7513 20.9528 15.7757 20.8878 15.812 20.8294C15.8483 20.771 15.8958 20.7203 15.9517 20.6802C16.0076 20.6401 16.0708 20.6114 16.1378 20.5958C16.2047 20.5801 16.2741 20.5778 16.342 20.589C16.4099 20.6002 16.4748 20.6246 16.5333 20.6609C16.5917 20.6972 16.6424 20.7446 16.6825 20.8005L18.001 22.6485L19.3196 20.8023C19.4006 20.6894 19.5231 20.6133 19.6601 20.5907C19.7972 20.5682 19.9376 20.601 20.0504 20.6819C20.1633 20.7629 20.2394 20.8853 20.262 21.0224C20.2845 21.1594 20.2517 21.2998 20.1708 21.4127V21.4101ZM14.6872 21.1066C14.6872 21.2454 14.632 21.3785 14.5339 21.4766C14.4358 21.5747 14.3027 21.6299 14.1639 21.6299H12.943V25.9903C12.943 26.129 12.8879 26.2621 12.7897 26.3602C12.6916 26.4584 12.5585 26.5135 12.4197 26.5135C12.281 26.5135 12.1479 26.4584 12.0498 26.3602C11.9516 26.2621 11.8965 26.129 11.8965 25.9903V21.6299H10.6756C10.5368 21.6299 10.4037 21.5747 10.3056 21.4766C10.2075 21.3785 10.1523 21.2454 10.1523 21.1066C10.1523 20.9678 10.2075 20.8348 10.3056 20.7366C10.4037 20.6385 10.5368 20.5834 10.6756 20.5834H14.1639C14.3027 20.5834 14.4358 20.6385 14.5339 20.7366C14.632 20.8348 14.6872 20.9678 14.6872 21.1066ZM25.8498 21.1066C25.8498 21.2454 25.7946 21.3785 25.6965 21.4766C25.5984 21.5747 25.4653 21.6299 25.3265 21.6299H24.1056V25.9903C24.1056 26.129 24.0505 26.2621 23.9523 26.3602C23.8542 26.4584 23.7211 26.5135 23.5823 26.5135C23.4436 26.5135 23.3105 26.4584 23.2124 26.3602C23.1142 26.2621 23.0591 26.129 23.0591 25.9903V21.6299H21.8382C21.6994 21.6299 21.5663 21.5747 21.4682 21.4766C21.3701 21.3785 21.3149 21.2454 21.3149 21.1066C21.3149 20.9678 21.3701 20.8348 21.4682 20.7366C21.5663 20.6385 21.6994 20.5834 21.8382 20.5834H25.3265C25.4653 20.5834 25.5984 20.6385 25.6965 20.7366C25.7946 20.8348 25.8498 20.9678 25.8498 21.1066Z" fill="#70B62B"/>
</g>
<defs>
<clipPath id="clip0_4_2602">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

@@ -1,18 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2568)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<g clip-path="url(#clip1_4_2568)">
<path d="M10.5 17V9C10.5 8.44772 10.9477 8 11.5 8H19.5858C19.851 8 20.1054 8.10536 20.2929 8.29289L25.2071 13.2071C25.3946 13.3946 25.5 13.649 25.5 13.9142V25C25.5 25.5523 25.0523 26 24.5 26H24" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M16.3178 24.0708L16.3178 21.9612L16.3178 20.4998C16.3178 20.2789 16.1387 20.0998 15.9178 20.0998L9.36526 20.0998C9.14435 20.0998 8.96526 20.2789 8.96526 20.4998L8.96526 25.6253C8.96526 25.8462 9.14435 26.0253 9.36526 26.0253L15.9178 26.0253C16.1387 26.0253 16.3178 25.8462 16.3178 25.6253L16.3178 24.0708ZM16.3178 21.9612L18.847 20.4588C19.1137 20.3004 19.4513 20.4925 19.4513 20.8027L19.4512 22.7678L19.4514 25.3043C19.4514 25.6183 19.1061 25.8099 18.8397 25.6437L16.3178 24.0708" stroke="#70B62B" stroke-width="0.88" stroke-linecap="square"/>
</g>
</g>
<defs>
<clipPath id="clip0_4_2568">
<rect width="35" height="35" fill="white"/>
</clipPath>
<clipPath id="clip1_4_2568">
<rect width="24" height="24" fill="white" transform="translate(6 5)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,13 +0,0 @@
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4_2635)">
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#E0E0E0"/>
<path d="M25.5 18.8366V13.9142C25.5 13.649 25.3946 13.3946 25.2071 13.2071L20.2929 8.29289C20.1054 8.10536 19.851 8 19.5858 8H11.5C10.9477 8 10.5 8.44772 10.5 9V17V18.8366" stroke="#151515" stroke-width="0.88" stroke-linecap="round"/>
<path d="M20.2422 8.45236V13.0423C20.2422 13.0975 20.287 13.1423 20.3422 13.1423H24.9322C25.0213 13.1423 25.0659 13.0346 25.0029 12.9716L20.4129 8.38165C20.3499 8.31865 20.2422 8.36327 20.2422 8.45236Z" stroke="#151515" stroke-width="0.88" stroke-linecap="square"/>
<path d="M11.5476 20.5859H10.1522C10.0134 20.5859 9.8803 20.6411 9.78217 20.7392C9.68404 20.8373 9.62891 20.9704 9.62891 21.1092V25.9931C9.62891 26.1319 9.68404 26.265 9.78217 26.3631C9.8803 26.4612 10.0134 26.5164 10.1522 26.5164H11.5476C12.334 26.5164 13.0882 26.204 13.6443 25.6479C14.2004 25.0918 14.5128 24.3376 14.5128 23.5511C14.5128 22.7647 14.2004 22.0105 13.6443 21.4544C13.0882 20.8983 12.334 20.5859 11.5476 20.5859ZM11.5476 25.4698H10.6755V21.6325H11.5476C12.0564 21.6325 12.5445 21.8346 12.9043 22.1944C13.2641 22.5543 13.4662 23.0423 13.4662 23.5511C13.4662 24.06 13.2641 24.548 12.9043 24.9078C12.5445 25.2677 12.0564 25.4698 11.5476 25.4698ZM26.2123 24.9936C26.3124 25.0897 26.3703 25.2215 26.3732 25.3602C26.3762 25.4988 26.3239 25.633 26.228 25.7332C26.0014 25.9776 25.7272 26.1731 25.4223 26.3078C25.1174 26.4425 24.7882 26.5134 24.455 26.5164C23.0125 26.5164 21.8386 25.1864 21.8386 23.5511C21.8386 21.9159 23.0125 20.5859 24.455 20.5859C24.7884 20.5891 25.1176 20.6603 25.4225 20.7953C25.7274 20.9303 26.0015 21.1261 26.228 21.3708C26.3194 21.4718 26.3679 21.6045 26.3631 21.7406C26.3583 21.8767 26.3006 22.0056 26.2022 22.0999C26.1039 22.1942 25.9726 22.2464 25.8364 22.2454C25.7002 22.2444 25.5697 22.1904 25.4727 22.0947C25.3433 21.9526 25.1864 21.8383 25.0114 21.7589C24.8364 21.6794 24.6471 21.6364 24.455 21.6325C23.5889 21.6325 22.8851 22.4933 22.8851 23.5511C22.8851 24.609 23.5889 25.4698 24.455 25.4698C24.647 25.4661 24.8362 25.4234 25.0112 25.3442C25.1861 25.2651 25.3432 25.1511 25.4727 25.0093C25.5687 24.9092 25.7006 24.8513 25.8392 24.8484C25.9779 24.8455 26.1121 24.8977 26.2123 24.9936ZM18.1757 20.5859C16.7332 20.5859 15.5593 21.9159 15.5593 23.5511C15.5593 25.1864 16.7332 26.5164 18.1757 26.5164C19.6182 26.5164 20.792 25.1864 20.792 23.5511C20.792 21.9159 19.6182 20.5859 18.1757 20.5859ZM18.1757 25.4698C17.3097 25.4698 16.6059 24.609 16.6059 23.5511C16.6059 22.4933 17.3097 21.6325 18.1757 21.6325C19.0417 21.6325 19.7455 22.4933 19.7455 23.5511C19.7455 24.609 19.0417 25.4698 18.1757 25.4698Z" fill="#70B62B"/>
</g>
<defs>
<clipPath id="clip0_4_2635">
<rect width="35" height="35" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -24,6 +24,7 @@ class FileDownloadRepository(private val context: Context) {
.get() .get()
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Не удалось скачать файл (HTTP ${response.code})") error("Не удалось скачать файл (HTTP ${response.code})")
} }
@@ -1,30 +1,26 @@
package ru.forbion.f7cloud.feature.files package ru.forbion.f7cloud.feature.files
object FileIcons { object FileIcons {
/** /** Те же SVG, что отдаёт F7cloud в `iconUrl` виджетов (mimeTypeIcon). */
* Иконка типа файла. Типы файлов ЛОКАЛЬНЫЕ SVG из дизайна (assets/filetypes/, грузятся
* coil-svg): офлайн + точные иконки макета (раньше тянулись с сервера аудит: офлайн ломался).
* Папка пока с сервера (иконки папки в наборе дизайна нет; TODO забрать из Figma).
*/
fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String { fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String {
if (isDirectory) return "${serverUrl.trimEnd('/')}/core/img/filetypes/folder.svg" val base = "${serverUrl.trimEnd('/')}/core/img/filetypes"
return "file:///android_asset/filetypes/${localIcon(name)}.svg" val icon = if (isDirectory) "folder" else iconFileName(name)
return "$base/$icon.svg"
} }
private fun localIcon(name: String): String { private fun iconFileName(name: String): String {
return when (name.substringAfterLast('.', "").lowercase()) { val ext = name.substringAfterLast('.', "").lowercase()
"doc", "docx", "dot", "dotx", "odt", "rtf" -> "word" return when (ext) {
"xls", "xlsx", "xlsm", "xlt", "xltx", "ods", "csv" -> "spreadsheet" "doc", "docx", "dot", "dotx", "odt", "rtf" -> "x-office-document"
"ppt", "pptx", "pot", "potx", "odp" -> "presentation" "xls", "xlsx", "xlsm", "xlt", "xltx", "ods", "csv" -> "x-office-spreadsheet"
"pdf" -> "pdf" "ppt", "pptx", "pot", "potx", "odp" -> "x-office-presentation"
"odg" -> "x-office-drawing"
"pdf" -> "application-pdf"
"jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "heic" -> "image" "jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "heic" -> "image"
"mp3", "wav", "ogg", "flac", "aac" -> "audio" "mp3", "wav", "ogg", "flac", "aac" -> "audio"
"mp4", "mkv", "avi", "mov", "webm" -> "video" "mp4", "mkv", "avi", "mov", "webm" -> "video"
"zip", "rar", "7z", "tar", "gz" -> "archive" "zip", "rar", "7z", "tar", "gz" -> "package-x-generic"
"txt", "md", "log", "rtf" -> "text" "txt", "md" -> "text"
"eml", "msg" -> "eml"
"crt", "cer", "pem", "key", "p12" -> "certificate"
"kt", "java", "js", "ts", "py", "c", "cpp", "h", "json", "xml", "html", "css", "sh", "go", "rs" -> "code"
else -> "file" else -> "file"
} }
} }
@@ -22,6 +22,7 @@ class FilesApiRepository {
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/configs" val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/configs"
val request = Request.Builder().url(url).get().build() val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Files config HTTP ${response.code}") error("Files config HTTP ${response.code}")
} }
@@ -47,6 +48,7 @@ class FilesApiRepository {
.put(value.toRequestBody("text/plain".toMediaType())) .put(value.toRequestBody("text/plain".toMediaType()))
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Files config save HTTP ${response.code}") if (!response.isSuccessful) error("Files config save HTTP ${response.code}")
} }
} }
@@ -56,6 +58,7 @@ class FilesApiRepository {
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/stats" val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/stats"
val request = Request.Builder().url(url).get().build() val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
return FilesStorageStats() return FilesStorageStats()
} }
@@ -75,6 +78,7 @@ class FilesApiRepository {
"?path=$encodedPath&depth=$depth&format=json" "?path=$encodedPath&depth=$depth&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build() val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
return emptyList() return emptyList()
} }
@@ -95,6 +99,7 @@ class FilesApiRepository {
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/recent/" val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/recent/"
val request = Request.Builder().url(url).get().build() val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Recent files HTTP ${response.code}") error("Recent files HTTP ${response.code}")
} }
@@ -126,6 +131,7 @@ class FilesApiRepository {
"?term=$encoded&format=json" "?term=$encoded&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build() val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
return emptyList() return emptyList()
} }
@@ -182,6 +188,7 @@ class FilesApiRepository {
.build() .build()
val request = Request.Builder().url(url).applyOcsJson().post(body).build() val request = Request.Builder().url(url).applyOcsJson().post(body).build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Не удалось создать ссылку (HTTP ${response.code})") error("Не удалось создать ссылку (HTTP ${response.code})")
} }
@@ -204,6 +211,7 @@ class FilesApiRepository {
"?path=$encodedPath&reshares=true&format=json" "?path=$encodedPath&reshares=true&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build() val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) return emptyList() if (!response.isSuccessful || response.body == null) return emptyList()
val root = parseJsonObject(response.body!!.string(), "общий доступ") val root = parseJsonObject(response.body!!.string(), "общий доступ")
val data = root.optJSONObject("ocs")?.opt("data") val data = root.optJSONObject("ocs")?.opt("data")
@@ -239,6 +247,7 @@ class FilesApiRepository {
"?format=json&object_type=files&object_id=$fileId&limit=50&sort=desc" "?format=json&object_type=files&object_id=$fileId&limit=50&sort=desc"
val request = Request.Builder().url(url).applyOcsJson().get().build() val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code == 204 || !response.isSuccessful || response.body == null) return emptyList() if (response.code == 204 || !response.isSuccessful || response.body == null) return emptyList()
val root = parseJsonObject(response.body!!.string(), "события") val root = parseJsonObject(response.body!!.string(), "события")
val data = root.optJSONObject("ocs")?.opt("data") val data = root.optJSONObject("ocs")?.opt("data")
@@ -1,250 +0,0 @@
package ru.forbion.f7cloud.feature.files
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
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.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.Color
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 ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Radius
import ru.forbion.f7cloud.core.designsystem.F7Spacing
enum class FilesViewMode { List, Grid }
/** Квадратная кнопка-переключатель вида список⇄сетка справа от поиска (макет F-17/F-22). */
@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))
.clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (viewMode == FilesViewMode.List) Icons.Filled.GridView else Icons.AutoMirrored.Filled.List,
contentDescription = "Сменить вид",
tint = F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
/** Хлебные крошки: домой + сегменты пути через ›, прокрутка по горизонтали (макет F-21/F-22). */
@Composable
fun FilesBreadcrumb(
segments: List<String>,
onHomeClick: () -> Unit,
onSegmentClick: (index: Int) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Home,
contentDescription = "В начало",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(22.dp)
.clickable(onClick = onHomeClick),
)
segments.forEachIndexed { i, seg ->
Text(" ", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Text(
text = seg,
style = MaterialTheme.typography.bodyMedium,
color = if (i == segments.lastIndex) F7Colors.TextPrimary else F7Colors.TextSecondary,
maxLines = 1,
modifier = Modifier.clickable { onSegmentClick(i) },
)
}
}
}
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (макет F-18/F-22). */
@Composable
fun FilesFilterChips(
onTypeClick: () -> Unit,
onParticipantsClick: () -> Unit,
onModifiedClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.xs),
horizontalArrangement = Arrangement.spacedBy(F7Spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip("Тип файла", onTypeClick)
FilterChip("Участники", onParticipantsClick)
FilterChip("Изменен", onModifiedClick)
}
}
@Composable
private fun FilterChip(label: String, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(F7Radius.pill))
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.pill))
.clickable(onClick = onClick)
.padding(horizontal = F7Spacing.md, vertical = F7Spacing.xs),
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
}
}
/** Карточка сетки: чекбокс + звезда сверху, крупная иконка по центру, имя + ⋮ снизу (макет F-17). */
@Composable
fun FilesGridCard(
serverUrl: String,
item: FileItem,
selected: Boolean,
favorite: Boolean,
onSelectedChange: () -> Unit,
onOpenClick: () -> Unit,
onMenuClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(F7Radius.lg))
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.lg))
.clickable(onClick = onOpenClick)
.padding(F7Spacing.md),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = selected,
onCheckedChange = { onSelectedChange() },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Spacer(Modifier.width(F7Spacing.sm))
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),
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = F7Spacing.lg),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null,
modifier = Modifier.size(48.dp),
)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = item.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Меню",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(18.dp)
.clickable(onClick = onMenuClick),
)
}
}
}
/** Пустое состояние: круг-иконка + заголовок + подзаголовок + опц. кнопка (макет F-11..16). */
@Composable
fun FilesEmptyState(
iconUrl: String,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 96.dp, start = F7Spacing.xxl, end = F7Spacing.xxl),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(F7Radius.pill))
.background(F7Colors.SurfaceMuted),
contentAlignment = Alignment.Center,
) {
AsyncImage(model = iconUrl, contentDescription = null, modifier = Modifier.size(32.dp))
}
Spacer(Modifier.size(F7Spacing.lg))
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Spacer(Modifier.size(F7Spacing.xs))
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
if (action != null) {
Spacer(Modifier.size(F7Spacing.xl))
action()
}
}
}
@@ -21,18 +21,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -58,7 +49,6 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
@@ -74,13 +64,8 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage import coil.compose.AsyncImage
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Radius
import ru.forbion.f7cloud.core.designsystem.F7Spacing
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@@ -557,11 +542,12 @@ fun FilesSearchBar(
serverUrl: String, serverUrl: String,
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
val base = serverUrl.trimEnd('/') val base = serverUrl.trimEnd('/')
Box( Box(
modifier = modifier modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.height(40.dp) .height(40.dp)
.shadow( .shadow(
elevation = 2.dp, elevation = 2.dp,
@@ -789,18 +775,16 @@ fun FilesListRow(
onMenuClick: (Rect) -> Unit, onMenuClick: (Rect) -> Unit,
) { ) {
var menuAnchor by remember { mutableStateOf<Rect?>(null) } var menuAnchor by remember { mutableStateOf<Rect?>(null) }
// Имя + расширение (расширение серым, инлайн) — по макету моб. Файлов (F-18/F-22). val displayName = if (showExtensions || item.isDirectory) {
val hasExt = !item.isDirectory && item.name.contains('.') item.name
val baseName = if (hasExt) item.name.substringBeforeLast('.') else item.name } else {
val extension = if (hasExt && showExtensions) "." + item.name.substringAfterLast('.') else "" item.name.substringBeforeLast('.', item.name)
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(F7Radius.md))
// выбранная строка — светло-зелёный фон (макет F-21)
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable(onClick = onOpenClick) .clickable(onClick = onOpenClick)
.padding(horizontal = F7Spacing.sm, vertical = 10.dp), .padding(horizontal = 4.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Checkbox( Checkbox(
@@ -811,25 +795,37 @@ fun FilesListRow(
AsyncImage( AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory), model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(28.dp),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
Text( Column(
text = buildAnnotatedString {
append(baseName)
if (extension.isNotEmpty()) {
withStyle(SpanStyle(color = F7Colors.TextSecondary)) { append(" $extension") }
}
},
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = F7Spacing.sm), .padding(horizontal = 8.dp),
) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary, maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val meta = buildList {
if (!item.isDirectory && item.size != null && item.size > 0) {
add(formatFileSize(item.size))
}
item.lastModified?.let { add(formatModified(it)) }
}.joinToString(" · ")
if (meta.isNotBlank()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
@@ -1008,10 +1004,10 @@ fun FilesDetailsSheet(
loading: Boolean, loading: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onTabChange: (FileDetailsTab) -> Unit, onTabChange: (FileDetailsTab) -> Unit,
onToggleFavorite: () -> Unit,
onWebOnlyAction: (String) -> Unit, onWebOnlyAction: (String) -> Unit,
) { ) {
if (item == null) return if (item == null) return
val base = serverUrl.trimEnd('/')
var commentDraft by remember(item.relativePath) { mutableStateOf("") } var commentDraft by remember(item.relativePath) { mutableStateOf("") }
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
Surface( Surface(
@@ -1026,26 +1022,9 @@ fun FilesDetailsSheet(
.fillMaxSize() .fillMaxSize()
.padding(20.dp), .padding(20.dp),
) { ) {
// Крестик закрытия — правый верхний угол (по макету)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Close,
contentDescription = "Закрыть",
tint = F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -1056,86 +1035,52 @@ fun FilesDetailsSheet(
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(6.dp))
// Чип владельца (аватар-инициал + логин) — по макету val metaParts = buildList {
Row( item.size?.let { add(formatFileSize(it)) }
modifier = Modifier item.lastModified?.let {
.clip(RoundedCornerShape(100.dp)) add(
.background(F7Colors.SurfaceMuted) SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)),
.padding(start = 4.dp, end = 10.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight),
contentAlignment = Alignment.Center,
) {
Text(
ownerLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.SemiBold,
) )
} }
add("Владелец $ownerLabel")
}
Text( Text(
ownerLabel, metaParts.joinToString(" "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary, color = F7Colors.TextSecondary,
maxLines = 1, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
} TextButton(onClick = onDismiss) {
Column(horizontalAlignment = Alignment.End) { Text("", style = MaterialTheme.typography.titleMedium)
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.bodySmall,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.height(8.dp))
}
Icon(
if (item.favorite) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = "Избранное",
tint = if (item.favorite) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.clickable(onClick = onToggleFavorite),
)
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
FilesDetailsTabPill( FilesDetailsTabButton(
icon = Icons.Filled.ChatBubbleOutline, serverUrl = serverUrl,
contentDescription = "Обсуждение", label = "Общий доступ",
selected = tab == FileDetailsTab.Events, iconPath = "files/many-person-gray.svg",
onClick = { onTabChange(FileDetailsTab.Events) },
modifier = Modifier.weight(1f),
)
FilesDetailsTabPill(
icon = Icons.Filled.Group,
contentDescription = "Общий доступ",
selected = tab == FileDetailsTab.Sharing, selected = tab == FileDetailsTab.Sharing,
onClick = { onTabChange(FileDetailsTab.Sharing) }, onClick = { onTabChange(FileDetailsTab.Sharing) },
modifier = Modifier.weight(1f), )
FilesDetailsTabButton(
serverUrl = serverUrl,
label = "События",
iconPath = "files/grid-files-gray.svg",
selected = tab == FileDetailsTab.Events,
onClick = { onTabChange(FileDetailsTab.Events) },
) )
} }
Spacer(Modifier.height(16.dp)) HorizontalDivider(
modifier = Modifier.padding(top = 8.dp),
color = F7Colors.Border.copy(alpha = 0.5f),
)
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -1178,28 +1123,42 @@ fun FilesDetailsSheet(
} }
@Composable @Composable
private fun FilesDetailsTabPill( private fun FilesDetailsTabButton(
icon: androidx.compose.ui.graphics.vector.ImageVector, serverUrl: String,
contentDescription: String, label: String,
iconPath: String,
selected: Boolean, selected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val shape = RoundedCornerShape(14.dp) val base = serverUrl.trimEnd('/')
Column(
modifier = Modifier.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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( Box(
modifier = modifier modifier = Modifier
.height(40.dp) .height(2.dp)
.clip(shape) .width(72.dp)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface) .clip(RoundedCornerShape(1.dp))
.border(1.dp, if (selected) Color.Transparent else F7Colors.Border, shape) .background(if (selected) F7Colors.Primary else Color.Transparent),
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = contentDescription,
tint = if (selected) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
) )
} }
} }
@@ -1395,24 +1354,8 @@ private fun FilesEventsTabContent(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onEnter = onSubmitComment, onEnter = onSubmitComment,
) )
Box( TextButton(onClick = onSubmitComment) {
modifier = Modifier Text("", fontSize = 20.sp)
.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( Text(
@@ -8,14 +8,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -41,7 +35,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
import ru.forbion.f7cloud.core.designsystem.F7Colors 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.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
@@ -68,7 +61,6 @@ fun FilesScreen(
val vm: FilesViewModel = viewModel(factory = FilesViewModel.Factory(context)) val vm: FilesViewModel = viewModel(factory = FilesViewModel.Factory(context))
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var viewMode by remember { mutableStateOf(FilesViewMode.List) }
var showCreateMenu by remember { mutableStateOf(false) } var showCreateMenu by remember { mutableStateOf(false) }
var showNewFolderDialog by remember { mutableStateOf(false) } var showNewFolderDialog by remember { mutableStateOf(false) }
var newFolderName by remember { mutableStateOf("") } var newFolderName by remember { mutableStateOf("") }
@@ -175,31 +167,23 @@ fun FilesScreen(
headerActions = {}, headerActions = {},
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Поиск + переключатель вид список⇄сетка (макет F-17/F-22) if (state.currentPath.isNotBlank() && state.browseMode == FilesBrowseMode.AllFiles) {
Row( F7SecondaryButton(
modifier = Modifier.fillMaxWidth(), text = "Назад",
horizontalArrangement = Arrangement.spacedBy(8.dp), onClick = { vm.goUp(session) },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 8.dp),
) { )
}
Text(
text = pathLabel,
style = MaterialTheme.typography.titleSmall,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(bottom = 8.dp),
)
FilesSearchBar( FilesSearchBar(
serverUrl = session.serverUrl, serverUrl = session.serverUrl,
query = state.searchQuery, query = state.searchQuery,
onQueryChange = { vm.setSearchQuery(session, it) }, onQueryChange = { vm.setSearchQuery(session, it) },
modifier = Modifier.weight(1f),
)
FilesViewToggleButton(viewMode) {
viewMode = if (viewMode == FilesViewMode.List) FilesViewMode.Grid else FilesViewMode.List
}
// Создание (загрузка/новая папка) — раньше вызывалось из нижней панели,
// теперь кнопка в шапке (нижняя панель стала навигационной)
F7CreateButton(onClick = { showCreateMenu = true }, size = 40.dp)
}
// Хлебные крошки
val crumbSegments = state.currentPath.split("/").filter { it.isNotBlank() }
FilesBreadcrumb(
segments = crumbSegments,
onHomeClick = { vm.navigateToPath(session, "") },
onSegmentClick = { i -> vm.navigateToPath(session, crumbSegments.take(i + 1).joinToString("/")) },
) )
if (selectionMode) { if (selectionMode) {
FilesBulkActionsBar( FilesBulkActionsBar(
@@ -211,11 +195,17 @@ fun FilesScreen(
}, },
) )
} else { } else {
// Фильтр-чипы вместо десктопной колоночной шапки (макет F-18/F-22) FilesListHeader(
FilesFilterChips( serverUrl = session.serverUrl,
onTypeClick = { vm.setSortColumn(FilesSortColumn.Type) }, showMimeColumn = state.userConfig.showMimeColumn,
onParticipantsClick = {}, sortColumn = state.sortColumn,
onModifiedClick = { vm.setSortColumn(FilesSortColumn.Modified) }, sortDirection = state.sortDirection,
selectionMode = false,
allSelected = allSelected,
onSelectAllToggle = {
if (allSelected) vm.clearSelection() else vm.selectAllVisible()
},
onSortColumnClick = vm::setSortColumn,
) )
} }
if (state.openingFile != null) { if (state.openingFile != null) {
@@ -233,65 +223,43 @@ fun FilesScreen(
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
) { ) {
val openItem: (FileItem) -> Unit = { item ->
val openable = !item.isDirectory &&
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
when {
item.isDirectory -> vm.openFolder(session, item)
openable -> vm.openItem(session, item)
}
}
if (displayItems.isEmpty() && !state.loading && state.error.isNullOrBlank()) { if (displayItems.isEmpty() && !state.loading && state.error.isNullOrBlank()) {
val searching = state.searchQuery.isNotBlank() Text(
FilesEmptyState( text = if (state.searchQuery.isNotBlank()) "Ничего не найдено" else "Папка пуста",
iconUrl = "file:///android_asset/filetypes/file.svg", style = MaterialTheme.typography.bodyMedium,
title = if (searching) "Нет результатов поиска" else "Папка пуста", modifier = Modifier.padding(top = 8.dp),
subtitle = if (searching) {
"По запросу «${state.searchQuery}» ничего не найдено"
} else {
"Загрузите контент или синхронизируйте его своими устройствами"
},
)
} else if (viewMode == FilesViewMode.Grid) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
gridItems(displayItems, key = { it.relativePath }) { item ->
FilesGridCard(
serverUrl = session.serverUrl,
item = item,
selected = item.relativePath in state.selectedPaths,
favorite = item.favorite,
onSelectedChange = { vm.toggleSelection(item.relativePath) },
onOpenClick = { openItem(item) },
onMenuClick = {
contextMenuItem = item
},
) )
} }
}
} else {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(displayItems, key = { it.relativePath }) { item -> itemsIndexed(displayItems, key = { _, item -> item.relativePath }) { index, item ->
val openable = !item.isDirectory &&
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
FilesListRow( FilesListRow(
serverUrl = session.serverUrl, serverUrl = session.serverUrl,
item = item, item = item,
selected = item.relativePath in state.selectedPaths, selected = item.relativePath in state.selectedPaths,
showExtensions = state.userConfig.showExtensions, showExtensions = state.userConfig.showExtensions,
onSelectedChange = { vm.toggleSelection(item.relativePath) }, onSelectedChange = { vm.toggleSelection(item.relativePath) },
onOpenClick = { openItem(item) }, onOpenClick = {
when {
item.isDirectory -> vm.openFolder(session, item)
openable -> vm.openItem(session, item)
}
},
onMenuClick = { rect -> onMenuClick = { rect ->
contextMenuItem = item contextMenuItem = item
contextMenuAnchor = rect contextMenuAnchor = rect
}, },
) )
if (index < displayItems.lastIndex) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
thickness = 1.dp,
color = F7Colors.Border.copy(alpha = 0.55f),
)
} }
} }
} }
@@ -364,7 +332,6 @@ fun FilesScreen(
loading = state.detailsLoading, loading = state.detailsLoading,
onDismiss = vm::hideDetails, onDismiss = vm::hideDetails,
onTabChange = vm::setDetailsTab, onTabChange = vm::setDetailsTab,
onToggleFavorite = { state.detailsItem?.let { vm.toggleFavorite(session, it) } },
onWebOnlyAction = vm::showWebOnlyMessage, onWebOnlyAction = vm::showWebOnlyMessage,
) )
@@ -29,6 +29,7 @@ class FilesTemplatesRepository {
.post(body) .post(body)
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data -> parseOcsData(response) { data ->
CreatedFile( CreatedFile(
fileId = data.optLong("fileid"), fileId = data.optLong("fileid"),
@@ -53,6 +54,7 @@ class FilesTemplatesRepository {
.post(body) .post(body)
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data -> parseOcsData(response) { data ->
data.optString("template_path") data.optString("template_path")
} }
@@ -72,6 +74,7 @@ class FilesTemplatesRepository {
.post(body) .post(body)
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data -> parseOcsData(response) { data ->
data.optString("url").ifBlank { data.optString("url").ifBlank {
error("Не удалось открыть описание папки") error("Не удалось открыть описание папки")
@@ -25,6 +25,7 @@ class RichdocumentsRepository {
.post(body) .post(body)
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) { if (!response.isSuccessful || response.body == null) {
error("Richdocuments HTTP ${response.code}") error("Richdocuments HTTP ${response.code}")
} }
+11 -11
View File
@@ -29,16 +29,16 @@ dependencies {
implementation project(':core:designsystem') implementation project(':core:designsystem')
implementation project(':feature:files') implementation project(':feature:files')
implementation project(':feature:contacts') implementation project(':feature:contacts')
implementation libs.coroutines.android implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation libs.json implementation 'org.json:json:20240303'
def composeBom = platform(libs.compose.bom) def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation 'androidx.compose.ui:ui'
implementation libs.compose.material3 implementation 'androidx.compose.material3:material3'
implementation libs.compose.foundation implementation 'androidx.compose.foundation:foundation'
implementation libs.compose.foundation.layout implementation 'androidx.compose.foundation:foundation-layout'
implementation libs.activity.compose implementation 'androidx.activity:activity-compose:1.9.0'
implementation libs.lifecycle.viewmodel.compose implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation libs.coil.compose implementation 'io.coil-kt:coil-compose:2.6.0'
implementation libs.coil.svg implementation 'io.coil-kt:coil-svg:2.6.0'
} }
-46
View File
@@ -1,46 +0,0 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ArgumentListWrapping:MailComposeScreen.kt$(hour, minute)</ID>
<ID>ArgumentListWrapping:MailComposeScreen.kt$(updatedDate, java.time.LocalTime.of(hour, minute))</ID>
<ID>ArgumentListWrapping:MailModels.kt$("""^\s*&lt;(?:!DOCTYPE|html|head|body|table|div|p|span|br|meta)\b""", RegexOption.IGNORE_CASE)</ID>
<ID>ArgumentListWrapping:MailScreen.kt$(context, it.message ?: "Ошибка загрузки", Toast.LENGTH_SHORT)</ID>
<ID>ComplexCondition:MailComposeActivity.kt$MailComposeActivity$username.isBlank() || serverUrl.isBlank() || accountId &lt;= 0 || accountEmail.isBlank()</ID>
<ID>ComplexCondition:MailRepository.kt$MailRepository$configured != null &amp;&amp; (role != "sent" || configured.specialRole != "inbox" &amp;&amp; !configured.isInbox)</ID>
<ID>CyclomaticComplexMethod:MailScreen.kt$@Composable fun MailScreen( session: AuthSession, modifier: Modifier = Modifier, openMessageId: Int? = null, openMailboxId: Int? = null, pushRefreshRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, settingsOpen: Boolean = false, onSettingsOpenChange: (Boolean) -&gt; Unit = {}, onOpenMessageConsumed: () -&gt; Unit = {}, onMessageOpenStateChange: (Boolean) -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>ImportOrdering:MailComponents.kt$import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally 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.FlowRow import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.fillMaxHeight 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MenuDefaults 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.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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle 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.DialogProperties import coil.compose.AsyncImage import kotlin.OptIn import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale import kotlin.math.abs import android.app.DatePickerDialog</ID>
<ID>ImportOrdering:MailScreen.kt$import android.widget.Toast 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.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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 androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider 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.F7OverlayDismissHandler import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.feature.files.LocalFileOpener import kotlinx.coroutines.flow.distinctUntilChanged import java.io.File</ID>
<ID>Indentation:MailScreen.kt$ </ID>
<ID>LargeClass:MailRepository.kt$MailRepository</ID>
<ID>LargeClass:MailViewModel.kt$MailViewModel : ViewModel</ID>
<ID>LongMethod:MailComponents.kt$@Composable fun MailMessageSenderCard( fromName: String, fromEmail: String, subject: String, to: String, cc: String, dateInt: Long, flagged: Boolean, seen: Boolean, important: Boolean, serverUrl: String, onReply: () -&gt; Unit, onToggleStar: () -&gt; Unit, onMarkUnread: () -&gt; Unit, onDelete: () -&gt; Unit, onToggleImportant: () -&gt; Unit, onForward: () -&gt; Unit, onMarkSpam: () -&gt; Unit, onEditTags: () -&gt; Unit, onMove: () -&gt; Unit, onSnooze: () -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:MailComponents.kt$@Composable fun MailNavigationSidebar( serverUrl: String, visible: Boolean, accounts: List&lt;MailAccount&gt;, folders: List&lt;MailFolderEntry&gt;, selected: MailFolderEntry?, collapsedAccountIds: Set&lt;Int&gt;, onDismiss: () -&gt; Unit, onComposeClick: () -&gt; Unit, onRefreshClick: () -&gt; Unit, onFolderClick: (MailFolderEntry) -&gt; Unit, onToggleAccountCollapsed: (Int) -&gt; Unit, onPriorityInboxClick: () -&gt; Unit, onOutboxClick: () -&gt; Unit, )</ID>
<ID>LongMethod:MailComponents.kt$@Composable fun MailSearchParamsSheet( visible: Boolean, params: MailSearchParams, onDismiss: () -&gt; Unit, onSearch: (MailSearchParams) -&gt; Unit, onClear: () -&gt; Unit, )</ID>
<ID>LongMethod:MailComposeScreen.kt$@Composable fun MailComposeScreen( session: AuthSession, launch: MailComposeLaunch, vm: MailComposeViewModel, onClose: () -&gt; Unit, onUnauthorized: () -&gt; Unit, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:MailFilesPickerActivity.kt$@Composable private fun MailFilesPickerScreen( session: AuthSession, title: String, onCancel: () -&gt; Unit, onUnauthorized: () -&gt; Unit, onFileSelected: (FileItem) -&gt; Unit, )</ID>
<ID>LongMethod:MailScreen.kt$@Composable fun MailScreen( session: AuthSession, modifier: Modifier = Modifier, openMessageId: Int? = null, openMailboxId: Int? = null, pushRefreshRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -&gt; Unit = {}, settingsOpen: Boolean = false, onSettingsOpenChange: (Boolean) -&gt; Unit = {}, onOpenMessageConsumed: () -&gt; Unit = {}, onMessageOpenStateChange: (Boolean) -&gt; Unit = {}, onUnauthorized: () -&gt; Unit = {}, )</ID>
<ID>LongMethod:MailScreen.kt$@Composable private fun MailInboxScreen( session: AuthSession, modifier: Modifier = Modifier, selectedFolder: MailFolderEntry?, loading: Boolean, loadingMore: Boolean, error: String?, searchQuery: String, searchParams: MailSearchParams, isOutbox: Boolean, messages: List&lt;MailMessage&gt;, outboxMessages: List&lt;MailOutboxMessage&gt;, nextCursor: Long?, onSearchChange: (String) -&gt; Unit, onQuickFilterToggle: (MailQuickFilter) -&gt; Unit, onComposeClick: () -&gt; Unit, onFilterClick: () -&gt; Unit, onMessageClick: (Int) -&gt; Unit, onOutboxRetry: (Int) -&gt; Unit, onOutboxDelete: (Int) -&gt; Unit, onLoadMore: () -&gt; Unit, )</ID>
<ID>LongMethod:MailScreen.kt$@Composable private fun MailMessageDetailView( session: AuthSession, detail: MailMessageDetail?, loading: Boolean, httpClient: okhttp3.OkHttpClient, modifier: Modifier = Modifier, showHeader: Boolean = true, htmlBodyExternal: Boolean = false, onAttachmentClick: (MailAttachment) -&gt; Unit, )</ID>
<ID>LongMethod:MailSettingsScreens.kt$@Composable private fun MailAccountSettingsScreen( settings: MailAccountSettings?, loading: Boolean, saving: Boolean, mailboxes: List&lt;MailMailbox&gt;, onBack: () -&gt; Unit, onSave: (MailAccountSettings) -&gt; Unit, )</ID>
<ID>LongMethod:MailSettingsScreens.kt$@Composable private fun MailAppSettingsScreen( serverUrl: String, accounts: List&lt;MailAccount&gt;, appSettings: MailAppSettings?, loading: Boolean, saving: Boolean, onAccountClick: (Int) -&gt; Unit, onShowThreadedChange: (Boolean) -&gt; Unit, onHighlightExternalChange: (Boolean) -&gt; Unit, onRemoveTrustedSender: (MailTrustedSender) -&gt; Unit, onCreateTextBlock: (String, String) -&gt; Unit, onDeleteTextBlock: (Int) -&gt; Unit, onDismiss: () -&gt; Unit, )</ID>
<ID>LoopWithTooManyJumpStatements:MailCacheRepository.kt$MailCacheRepository$for</ID>
<ID>LoopWithTooManyJumpStatements:MailRepository.kt$MailRepository$for</ID>
<ID>LoopWithTooManyJumpStatements:MailRepository.kt$MailRepository$while</ID>
<ID>MatchingDeclarationName:MailRichTextEditor.kt$MailRichTextEditorController</ID>
<ID>MaximumLineLength:MailComposeActivity.kt$MailComposeActivity.Companion$ </ID>
<ID>MaximumLineLength:MailComposeScreen.kt$ </ID>
<ID>MaximumLineLength:MailComposeViewModel.kt$MailComposeViewModel$ </ID>
<ID>MaximumLineLength:MailModels.kt$private</ID>
<ID>MaximumLineLength:MailScreen.kt$ </ID>
<ID>MaximumLineLength:MailViewModel.kt$MailViewModel$ </ID>
<ID>NestedBlockDepth:MailRepository.kt$MailRepository$private fun parseMailboxes(data: Any): List&lt;MailMailbox&gt;</ID>
<ID>NoUnusedImports:MailComponents.kt$ru.forbion.f7cloud.feature.mail.MailComponents.kt</ID>
<ID>NoUnusedImports:MailComposeScreen.kt$ru.forbion.f7cloud.feature.mail.MailComposeScreen.kt</ID>
<ID>NoUnusedImports:MailRepository.kt$ru.forbion.f7cloud.feature.mail.MailRepository.kt</ID>
<ID>PropertyWrapping:MailModels.kt$private val HTML_BODY_HINT = Regex("""^\s*&lt;(?:!DOCTYPE|html|head|body|table|div|p|span|br|meta)\b""", RegexOption.IGNORE_CASE)</ID>
<ID>UnusedParameter:MailRepository.kt$MailRepository$serverUrl: String</ID>
<ID>UnusedPrivateMember:MailComponents.kt$@Composable private fun MailTagChip(tag: MailTag)</ID>
<ID>UnusedPrivateProperty:MailComponents.kt$private val StarColor = Color(0xFFF9CF3D)</ID>
</CurrentIssues>
</SmellBaseline>
@@ -150,8 +150,7 @@ class MailCacheRepository(context: Context) {
private fun parseFolderPage(json: JSONObject): MailMessagesPage = private fun parseFolderPage(json: JSONObject): MailMessagesPage =
MailMessagesPage( MailMessagesPage(
messages = parseMessages(json.optJSONArray("messages")), messages = parseMessages(json.optJSONArray("messages")),
// org.json может вернуть число как Integer или Long — берём через Number.toLong(). nextCursor = json.opt("nextCursor").takeUnless { it == JSONObject.NULL } as? Int,
nextCursor = (json.opt("nextCursor").takeUnless { it == JSONObject.NULL } as? Number)?.toLong(),
) )
private fun messageDetailToJson(detail: MailMessageDetail): JSONObject = private fun messageDetailToJson(detail: MailMessageDetail): JSONObject =
@@ -663,13 +663,6 @@ fun MailEnvelopeRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
// Теги письма цветными пилюлями (макет «Список сообщений»)
if (message.tags.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
message.tags.take(4).forEach { MailTagChip(it) }
}
}
} }
if (unread) { if (unread) {
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
@@ -710,11 +703,10 @@ private fun tagChipColors(tag: MailTag): TagChipColors {
text = parsed.copy(alpha = 0.95f), text = parsed.copy(alpha = 0.95f),
) )
} }
// Точные цвета чипов из макета «Почта · Список сообщений» (Figma, сняты пипеткой)
return when (tag.displayName.lowercase(Locale("ru"))) { return when (tag.displayName.lowercase(Locale("ru"))) {
"работа", "work" -> TagChipColors(Color(0xFFDAEDD6), Color(0xFF28A413)) "работа", "work" -> TagChipColors(Color(0xFFE8F5D6), Color(0xFF5A7A2E))
"позже", "later" -> TagChipColors(Color(0xFFF1EEE0), Color(0xFFC1B464)) "позже", "later" -> TagChipColors(Color(0xFFF5F0E6), Color(0xFF8A7345))
"личное", "personal" -> TagChipColors(Color(0xFFEFE4FB), Color(0xFFB574F7)) "личное", "personal" -> TagChipColors(Color(0xFFEDE8F5), Color(0xFF6A558A))
else -> TagChipColors(F7Colors.SurfaceMuted, F7Colors.TextSecondary) else -> TagChipColors(F7Colors.SurfaceMuted, F7Colors.TextSecondary)
} }
} }
@@ -70,14 +70,6 @@ fun MailMessageBodyView(
settings.domStorageEnabled = false settings.domStorageEnabled = false
settings.loadsImagesAutomatically = true settings.loadsImagesAutomatically = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE 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 isVerticalScrollBarEnabled = true
isNestedScrollingEnabled = true isNestedScrollingEnabled = true
setBackgroundColor(0x00000000) setBackgroundColor(0x00000000)

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