Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 374e8838e8 | |||
| 47b2638b2b | |||
| b8e08d1a97 | |||
| fd341cb22a | |||
| b8a40afe3a | |||
| 5daaab732d | |||
| cac214340e | |||
| 0a36abe695 | |||
| 4aa732846d | |||
| 7f95b07666 | |||
| 92e0e9690b | |||
| 2cf5977dd4 | |||
| ab864aa7d7 | |||
| 2df46667e0 | |||
| 9ec3114506 | |||
| 8204e605e2 | |||
| 84a1ea9f27 | |||
| 753b574e1a | |||
| 3da61da45d | |||
| 999b52eb80 | |||
| 4c7566bbb2 | |||
| 65805a15af | |||
| bc9a31a026 | |||
| bbfb6ae424 | |||
| 1bc568b059 | |||
| 6895f1bffd | |||
| e5bbf0e123 | |||
| 2758d07e47 | |||
| c6732546f1 | |||
| dd5bdbc177 | |||
| 47534ceb59 | |||
| 8c90e83cef | |||
| a674721353 | |||
| 8f7eaff611 | |||
| 83e071673c | |||
| d1d61d0cf8 | |||
| 286ab9c8bf | |||
| b03cb09a6c | |||
| fb57156ee9 | |||
| 4c403e5ec2 | |||
| 00daa980a8 | |||
| 2709dba03f | |||
| c53885b230 | |||
| a696d45f2e | |||
| 2ae03264ff | |||
| 47c274e441 | |||
| 34bb16c59d | |||
| 0fcc575e76 | |||
| fff360fe39 | |||
| d047dea210 | |||
| cfa9fe2ffd | |||
| e25dcbc403 | |||
| 342e1e497d | |||
| 6f82a3fc56 | |||
| fe330be510 | |||
| 58d56feb76 | |||
| 58eed74b03 | |||
| 8186031676 | |||
| 9e5a16a605 | |||
| b56c9a5998 | |||
| d50eaaed3d | |||
| 3824ad86f2 | |||
| 89e32d2875 | |||
| a30af98a8d | |||
| fda34a5b69 | |||
| 3d9f3819d9 | |||
| 04f67e071b | |||
| b83d3dc872 | |||
| 530663754a | |||
| 8cee4d61bf | |||
| 2220fe4a89 | |||
| a727e2dbd1 | |||
| 335468b80f | |||
| b15a97d30e | |||
| 79e6b4ec83 | |||
| 8c3d422c85 | |||
| 483e4107d7 | |||
| 696ed8d802 | |||
| f280e19418 | |||
| 078fffd405 | |||
| 04227fa5c8 | |||
| 312537cd8a | |||
| fd96ca89e6 | |||
| f006a69b5c | |||
| ac438269c5 | |||
| ab8fa70e51 | |||
| c45ebd8d49 | |||
| d9cacb8c9c | |||
| f0708a30ac | |||
| b90107a7fc | |||
| cef526e778 | |||
| 5eb6efb36b |
@@ -0,0 +1,72 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# ⏸️ ПАРКОВКА (2026-07-09): CI отложен. Раннер на инфре B развёрнут, но
|
||||||
|
# host-исполнение act_runner 0.2.13 в нашем окружении сломано (криво резолвит
|
||||||
|
# пути к шагам/JS-экшенам: MODULE_NOT_FOUND / "No such file", задвоение
|
||||||
|
# hostexecutor/.cache/act). Рабочий путь — Docker-исполнение с кастомным образом
|
||||||
|
# (JDK 21 + Android SDK 36) и volume для ~/.gradle. До этого — сборки/релизы вручную
|
||||||
|
# на билд-машине (см. docs/CI.md).
|
||||||
|
#
|
||||||
|
# Триггер временно ручной (workflow_dispatch), чтобы push'и не создавали падающие
|
||||||
|
# прогоны. Когда настроим Docker-раннер — вернуть блок on: push/pull_request ниже.
|
||||||
|
#
|
||||||
|
# on:
|
||||||
|
# push:
|
||||||
|
# branches: [main]
|
||||||
|
# tags: ['v*']
|
||||||
|
# pull_request:
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
# detekt + unit-тесты + Android Lint + 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
|
||||||
@@ -24,3 +24,4 @@
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
.cxx/
|
.cxx/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -2,6 +2,66 @@
|
|||||||
|
|
||||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||||
|
|
||||||
|
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
|
||||||
|
|
||||||
|
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
|
||||||
|
|
||||||
|
- 2026-07-10 | v0.5.126 (134) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.124/125). **Пуши/звонки**: клиент собран под верный Firebase-проект f7push (google-services.json владельца; раньше токены уходили в чужой проект → сервер получал «SenderId mismatch», звонки не доходили); входящий звонок звонит 30 с, затем авто-сброс и уведомление «Пропущенный звонок» (на локскрине обезличенно, после разблокировки — с названием комнаты; страховочный alarm на случай смерти процесса). **Дизайн 1:1 по живой теме forbion** (мобильный CSS боевого сервера — новый источник истины): Файлы (строки 44dp с колонками размер/дата, фирменные чекбоксы/иконки офлайн, навигация с кнопкой «Создать или загрузить», панель выделения, свойства, поповеры), Почта (карточки писем с превью-строкой, навигация, sender-card, композер с чипами получателей, модалка меток, пустая папка), Конференции (автор внутри пузыря, вложения-карточки с иконкой типа), Календарь (месяц: ячейки-карточки 120dp, «сегодня»-плашка, пилюли событий), Контакты (типографика), логин (фон, локальный логотип, QR-кнопка), векторный сплэш вместо мыльного растра, семантика ошибок #D74642. **Новые функции**: «Создать задачу» и «Создать событие» из письма (меню ⋮, префилл темой, CalDAV); фильтры почты — общие с веб-версией (штатный API /api/filter: все правила видны, вкл/выкл/удаление, редактирование простых) | +GET/PUT /apps/mail/api/filter/{id}; feature:mail → зависимости feature:tasks, feature:calendar | средний: большой объём UI-правок + новый auth-жизненный цикл (MainViewModel) — проверить вход/выход/поворот, звонки, фильтры на устройстве
|
||||||
|
|
||||||
|
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.2 — календарь): кэш событий по CTag. `listCalendars` дополнительно забирает `cs:getctag` (тот же PROPFIND — лишних запросов нет). Новый `CalendarEventsCache` — дисковый кэш сырых REPORT-ответов per (аккаунт, календарь, диапазон) с ключом-CTag: календарь не менялся → события парсятся с диска, REPORT в сеть не идёт; менялся → сеть + обновление кэша (+чистка записей >30 дней). Сырой XML вместо сериализации моделей — переиспользуется боевой парсер. Эффект: переключение месяцев/периодический рефреш/возврат в календарь при неизменных данных — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов | контракты не менялись | низкий: при любом изменении CTag меняется — данные всегда свежие
|
||||||
|
|
||||||
|
- 2026-07-09 | (в разработке) | Этап 2 (DAV, ч.1 — контакты): инкрементальная синхронизация по CTag. `CardDavClient.collectionSignature()` — дешёвый PROPFIND depth:1 за CTag адресных книг (расширение CalendarServer, поддерживается Nextcloud; фолбэк на sync-token), без address-data. `ContactsRepository.syncContacts` перед полной закачкой всех vCard сверяет подпись коллекций: не менялось → отдаём кэш (Room), сеть не грузим. Ускоряет pull-to-refresh (было — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста (regex-парсер подписи, без Android XmlPull). Дальше по Этапу 2: кэш событий календаря + CTag (сейчас экран календаря тянет события живьём по диапазону — под него сначала нужен кэш) | контракты не менялись | низкий: только оптимизация, поведение то же при изменениях
|
||||||
|
|
||||||
|
- 2026-07-09 | v0.5.123 (131) | Первый тегированный релиз в Gitea (тег `v0.5.123` + Release с APK и архивом исходников GPL). Включает хвосты этапов 0/1: FLAG_SECURE на экранах входа/PIN (аудит п.3, коммит cfa9fe2) + активированный CI-раннер на инфре B (docs/CI). Функциональных изменений в приложении относительно v0.5.122 нет, кроме FLAG_SECURE | контракты не менялись | низкий
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.122 (130) | Убрана кнопка «чаты» из нижней панели (слева теперь всегда «назад», единый layout во всех разделах, включая Конференции) + убрана кнопка обновления ↻ сверху справа в Конференциях (onRefresh у F7ModuleScreen; список грузится при открытии/по push) | контракты не менялись | низкий: только UI
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.121 (129) | Нижняя панель: убрана зелёная подложка под активной кнопкой (фон всегда нейтральный). Активный раздел больше не выделяется фоном (зелёных вариантов иконок разделов нет). Зелёные иконки чатов/бургера при открытии остаются | контракты не менялись | низкий: только фон кнопок
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.120 (128) | Нижняя панель: иконки-разделы (Почта/Карточки/Конференции) крупнее — 46dp (у серверных SVG внутренний «воздух», глиф оставался мелким). Стрелка/бургер — прежние 34dp | контракты не менялись | низкий: только размер иконок
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.119 (127) | Убран чекбокс «Доверять сертификату» с экрана входа (раньше только в debug, теперь совсем). trustAllCerts=false по умолчанию; trust-all на сети/WebView в release и так отключён | контракты не менялись | низкий: только UI входа
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.118 (126) | Первая RELEASE-сборка на боевом ключе владельца (release-keystore.jks, alias f7cloud, SHA-256 5F:E5:F0:6C…). Раньше отдавались debug-сборки (debug-ключ сервера) → Play Защита ругалась «незнакомый разработчик». Теперь release, подписан ключом владельца → предупреждения нет, обновления ставятся поверх. signing.env читает креды из /root/.f7cloud-keys/key.properties (в git НЕ коммитится). + иконки нижней панели крупнее (24dp→34dp, заполняют кружок) | контракты не менялись | средний: смена контура подписи на боевой ключ; debug-сборку v117 надо удалить перед установкой (иная подпись)
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.117 (125) | Нижняя панель → навигационная по дизайну «Новое меню» (other/Новое меню.png): слева назад (в Конференциях — чаты), ярлыки-разделы Почта · Карточки · Конференции (активный подсвечен), справа выдвижное меню. Профиль/Уведомления/Настройки/Создать убраны из панели (первые три — в выдвижном меню, бейдж уведомлений на кнопке меню). `F7BottomBarConfig` упрощён, `F7MobileBottomBar` +слоты SectionMail/Cards/Conferences +activeTabKey. Создание Файлов/Контактов вызывалось ТОЛЬКО из панели → новый `F7CreateButton` (зелёный «+») в шапках Файлов и Контактов (Задачи/Поддержка/Почта свои имели) | контракты не менялись | средний: изменена навигация нижней панели; проверить переходы разделов и создание в Файлах/Контактах
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.117 (125) | Дизайн выдвижного меню: сетка 3 колонки (было 4), ячейка кликабельна целиком, подпись во всю ширину. Состав пунктов точно по дизайну — Файлы/Календарь/Контакты/Карточки/Заметки + Bitrix/1C/Личный кабинет + Уведомления/Настройки (убраны Почта/Конференции/Задачи/Поддержка — они в нижней панели). Уведомления→шит, Настройки→настройки активного таба (нативные пункты рисуются локальной иконкой в круглом бейдже) | контракты не менялись | низкий: состав/вид меню
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.117 (125) | Нативная панель профиля вместо веб-ЛК forbion: «Личный кабинет» открывал внешнюю ссылку (десктопный дропдаун forbion, криво на моб.) → теперь нативный bottom-card (ProfileSheet): аватар-инициал + имя (OCS cloud/user) + Открыть профиль/QR + Установить статус/Личные настройки/Учётные записи (deep-link веб) + О программе (нативный About) + Выйти. Точки входа: пункт меню + кнопка профиля | контракты не менялись; +GET ocs/cloud/user (имя) | низкий: только панель профиля
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.117 (125) | Fix insets (edge-to-edge, targetSdk 36): контент залезал под системную навигацию снизу — добавлен нижний inset (`f7SafeBottomInsets`) к контенту в F7AppScaffold (верх уже был). | контракты не менялись | низкий: отступы контента
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.117 (125) | Fix отображения писем: HTML-письма (широкие таблицы Битрикс/рассылок) уезжали за правый край — WebView без useWideViewPort игнорировал viewport width=device-width. Включены useWideViewPort+loadWithOverviewMode+пинч-зум | контракты не менялись | низкий: только рендер письма
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.116 (124) | Меню-лончер (сетка приложений) → bottom-sheet на пол-экрана (требование владельца «выходить снизу вверх только на пол экрана»). `F7AppMenuSheet` был `fillMaxSize` с непрозрачным фоном → закрывал весь экран. Теперь: якорь снизу (`Alignment.BottomCenter`), высота `fillMaxHeight(0.6f)`, скруглённый верх (20dp) + тень, ручка-хендл, лёгкий скрим (0.18) над листом с тапом-для-закрытия, зазор `BottomBarReserve` под нижнюю панель, сетка иконок скроллится (`weight(1f)`). Экран под меню теперь виден сверху. Компилируется | контракты не менялись | низкий: только оверлей меню
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.115 (123) | Бамп версии под дизайн-веху (0.5.114→0.5.115, code 122→123, стиль проекта: патч +1 / code +1). До этого Этапы 0/1 + весь дизайн ошибочно шли одной WIP-линией 0.5.114 (122) — теперь ревью-сборки дизайна помечены отдельной версией. Дальше — бампить на каждую заметную веху/ревью-сборку | версионирование | нет: только versionCode/Name
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.114 (122) | Дизайн Календаря (месячная сетка): по «Календарь · Месяц» — дни недели вынесены в серые пилюли; сетка стала безрамочной с тонкими гридлайнами (0.5dp Grey3) вместо скруглённых боксов; номер дня сверху-слева; сегодня — белое число в зелёном круге; событие — зелёная пилюля PrimaryLight (до 2 шт + «+N») вместо одного превью/точки. Компактная мобильная модель (месяц сверху + список событий выбранного дня снизу) сохранена — не десктопная таблица. `CalendarMonthGrid`/`CalendarDayCell`, убран неиспользуемый параметр daysWithEvents. Проверено сборкой | контракты не менялись | низкий: только вид месяца
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (правая панель свойств/доступа): `FilesDetailsSheet` по «Правая панель свойства/доступ» — табы из текст+подчёркивание → две иконки-пилюли (Обсуждение 💬 / Доступ 👥, активная — зелёная заливка PrimaryLight); шапка: крестик в правом верхнем углу, чип владельца (аватар-инициал + логин), дата/размер справа, звезда-избранное (wired на vm.toggleFavorite) вместо строки «Владелец…»; кнопка отправки комментария → зелёный круг-градиент с белой стрелкой. Иконки — Material (extended уже в модуле). Проверено сборкой | контракты не менялись | низкий: визуал панели, логика (шэры/события/избранное) та же
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.114 (122) | Дизайн Конференций (подложка чата): `TalkChatBackground` грузил chat-pattern.svg с сервера поверх плоского фона → заменён на бандл-ресурс `R.drawable.chat_background` (Подложка2 из дизайна — светлый градиент + водяной паттерн иконок, владелец выгрузил). PNG 481КБ → WebP 49КБ (q82, ×10), `feature/talk/res/drawable-nodpi`. ContentScale.Crop, full-bleed. Офлайн + точно из макета. Проверено сборкой | контракты не менялись | низкий: только фон экрана чата
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.114 (122) | Дизайн Почты (теги + сверка экрана сообщения): цвета тегов-пилюль сняты пипеткой с макета «Почта · Список сообщений» — Работа #DAEDD6/#28A413, Позже #F1EEE0/#C1B464, Личное #EFE4FB/#B574F7 (fallback tagChipColors, когда сервер не отдаёт colorHex; раньше приблизительные). Экран СООБЩЕНИЯ сверен с «Почта · Окно сообщения» — уже совпадает (тема-заголовок, sender-card, тело, плавающая пилюля «↩ Ответить» MailReplyFab на градиенте Primary), правок не требует. Проверено сборкой | контракты не менялись | низкий: только цвета тегов
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.114 (122) | Дизайн-палитра из Figma «Брендбук» (владелец) в F7Colors: точные Base-токены (Black #151515, Grey1 #808080, Grey2 #F5F5F5, Grey3 #E6E6E6, Green #70B62B +30%/10%, Yellow #F6E120, Purple #9747FF, Red #FF7A66 +10%). Семантические токены (TextPrimary/Secondary/Border/SurfaceMuted/Primary) переведены на Base — основа сверена, совпала. **ИСПРАВЛЕНО расхождение: Error #D74642 → #FF7A66 (дизайн-красный)** — влияет на кнопки удаления/ошибки во ВСЁМ приложении. Yellow/Purple — под теги. Пропагируется на все экраны; шаг к закрытию аудитного долга «77 захардкоженных Color(0x…)» | дизайн-система | средний: Error-красный меняется app-wide (по дизайну); проверить экраны удаления/ошибок
|
||||||
|
|
||||||
|
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 3 — каркас списка): рескин главного экрана под мобильный макет (Figma files/*.png на инфре B). Новый FilesBrowserChrome.kt: поиск-пилюля + кнопка-переключатель список⇄сетка, хлебные крошки (🏠 › путь, навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) ВМЕСТО десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). Строки без разделителей (по макету). На токенах F7Spacing/F7Radius. material-icons-extended → feature:files (иконки chrome, потом точные из дизайна). Компилируется | контракты не менялись | средний: заметный UI-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве
|
||||||
|
|
||||||
|
- 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 затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
|
||||||
|
|||||||
@@ -39,16 +39,14 @@ android {
|
|||||||
applicationId 'ru.forbion.f7cloud.mobile'
|
applicationId 'ru.forbion.f7cloud.mobile'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 122
|
versionCode 138
|
||||||
versionName '0.5.114'
|
versionName '0.5.130'
|
||||||
missingDimensionStrategy 'default', 'f7'
|
missingDimensionStrategy 'default', 'f7'
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
|
||||||
// Универсальный APK для реальных устройств: только ARM (arm64 + armeabi-v7a),
|
// ABI задаём ПОБИЛДТИПОВО (ниже): release — ARM-only (компактно), debug — все ABI
|
||||||
// без эмуляторных x86/x86_64 — режет ~половину native-веса (WebRTC .so).
|
// (включая x86/x86_64), чтобы debug-сборка нативно шла на эмуляторах (BlueStacks и т.п.)
|
||||||
ndk {
|
// и на физических устройствах.
|
||||||
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
|
||||||
}
|
|
||||||
// Оставляем только нужные локали (у vendor-форка ~48 языков) — минус несколько МБ.
|
// Оставляем только нужные локали (у vendor-форка ~48 языков) — минус несколько МБ.
|
||||||
// resourceConfigurations — не-deprecated бэкинг resConfigs.
|
// resourceConfigurations — не-deprecated бэкинг resConfigs.
|
||||||
resourceConfigurations += ['ru', 'en']
|
resourceConfigurations += ['ru', 'en']
|
||||||
@@ -62,11 +60,16 @@ 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
|
||||||
}
|
}
|
||||||
@@ -108,30 +111,31 @@ dependencies {
|
|||||||
implementation project(':feature:mail')
|
implementation project(':feature:mail')
|
||||||
implementation project(':feature:f7support')
|
implementation project(':feature:f7support')
|
||||||
|
|
||||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
def composeBom = platform(libs.compose.bom)
|
||||||
implementation composeBom
|
implementation composeBom
|
||||||
androidTestImplementation composeBom
|
androidTestImplementation composeBom
|
||||||
implementation 'androidx.activity:activity-compose:1.10.1'
|
implementation libs.activity.compose
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation libs.compose.ui
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation libs.compose.ui.tooling.preview
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation libs.compose.material3
|
||||||
implementation 'androidx.navigation:navigation-compose:2.8.9'
|
implementation libs.navigation.compose
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
|
implementation libs.lifecycle.viewmodel.compose
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
|
implementation libs.lifecycle.runtime.compose
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
|
implementation libs.lifecycle.process
|
||||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
implementation libs.coil.compose
|
||||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
implementation libs.coil.svg
|
||||||
implementation platform('com.google.firebase:firebase-bom:33.7.0')
|
implementation platform(libs.firebase.bom)
|
||||||
implementation 'com.google.firebase:firebase-messaging'
|
implementation libs.firebase.messaging
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0'
|
implementation libs.coroutines.play.services
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation libs.multidex
|
||||||
implementation 'androidx.biometric:biometric:1.1.0'
|
implementation libs.core.splashscreen
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.8.6'
|
implementation libs.biometric
|
||||||
implementation 'androidx.camera:camera-camera2:1.5.2'
|
implementation libs.fragment.ktx
|
||||||
implementation 'androidx.camera:camera-lifecycle:1.5.2'
|
implementation libs.camera.camera2
|
||||||
implementation 'androidx.camera:camera-view:1.5.2'
|
implementation libs.camera.lifecycle
|
||||||
implementation 'com.google.zxing:core:3.3.0'
|
implementation libs.camera.view
|
||||||
implementation 'androidx.compose.material:material-icons-extended'
|
implementation libs.zxing
|
||||||
implementation 'com.google.guava:guava:33.3.1-android'
|
implementation libs.compose.material.icons.extended
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
implementation libs.guava
|
||||||
|
debugImplementation libs.compose.ui.tooling
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
android:resource="@mipmap/ic_launcher" />
|
android:resource="@mipmap/ic_launcher" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:theme="@style/Theme.F7.Splash"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
|
After Width: | Height: | Size: 57 KiB |
@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
|||||||
*/
|
*/
|
||||||
class CallIncomingActivity : ComponentActivity() {
|
class CallIncomingActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private var callEndedListener: ((String) -> Unit)? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Звонок сняли извне (30-с таймаут «не берут трубку», принят/отклонён из
|
||||||
|
// уведомления) — закрываем полноэкранный входящий.
|
||||||
|
launch.roomToken?.takeIf { it.isNotBlank() }?.let { token ->
|
||||||
|
val listener: (String) -> Unit = { ended ->
|
||||||
|
if (ended == token) {
|
||||||
|
runOnUiThread {
|
||||||
|
if (!isFinishing) finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callEndedListener = listener
|
||||||
|
F7IncomingCallQueue.addCallEndedListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
F7Theme {
|
F7Theme {
|
||||||
IncomingCallScreen(
|
IncomingCallScreen(
|
||||||
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
|
||||||
|
callEndedListener = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -37,6 +38,7 @@ 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 {
|
||||||
@@ -75,7 +77,31 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(): ImageLoader {
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
// OkHttp с Basic-auth для хоста сервера: серверные картинки (иконки внешних сайтов
|
||||||
|
// /apps/external/img, превью и т.п.) требуют логина — без заголовка coil ловит 401.
|
||||||
|
val authedClient = okhttp3.OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
val session = runCatching { AuthStore(this).load() }.getOrNull()
|
||||||
|
val serverHost = session?.serverUrl?.let {
|
||||||
|
runCatching { android.net.Uri.parse(it).host }.getOrNull()
|
||||||
|
}
|
||||||
|
val patched = if (session != null && serverHost != null && request.url.host == serverHost) {
|
||||||
|
request.newBuilder()
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
okhttp3.Credentials.basic(session.username, session.appPassword),
|
||||||
|
)
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
}
|
||||||
|
chain.proceed(patched)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
return ImageLoader.Builder(this)
|
return ImageLoader.Builder(this)
|
||||||
|
.okHttpClient(authedClient)
|
||||||
.components { add(SvgDecoder.Factory()) }
|
.components { add(SvgDecoder.Factory()) }
|
||||||
.crossfade(false)
|
.crossfade(false)
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.util.Log
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -43,6 +44,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Системный сплэш (фон + иконка) на холодном старте вместо белого экрана
|
||||||
|
installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
if (!handleIncomingIntent(intent)) {
|
if (!handleIncomingIntent(intent)) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||||
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
|
||||||
|
|
||||||
@@ -145,6 +146,7 @@ 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()
|
||||||
@@ -534,6 +536,7 @@ 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("") }
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -49,7 +48,7 @@ object AppMenuRepository {
|
|||||||
}
|
}
|
||||||
AppMenuExternalSite(
|
AppMenuExternalSite(
|
||||||
name = name,
|
name = name,
|
||||||
iconUrl = icon ?: defaultExternalIcon(session.serverUrl, name),
|
iconUrl = resolveExternalIcon(session.serverUrl, icon, name),
|
||||||
openUrl = openUrl,
|
openUrl = openUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -57,12 +56,18 @@ object AppMenuRepository {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultExternalIcon(serverUrl: String, name: String): String {
|
/**
|
||||||
|
* Иконка внешнего сайта. API external отдаёт `icon` как ИМЯ ФАЙЛА (напр. «bitrix24.png»),
|
||||||
|
* а не URL — сайт грузит его с `/apps/external/img/{icon}` (см. layout.user.php). Раньше
|
||||||
|
* имя файла бралось как есть → битая ссылка → глобус-заглушка у Bitrix/1C.
|
||||||
|
*/
|
||||||
|
private fun resolveExternalIcon(serverUrl: String, icon: String?, name: String): String {
|
||||||
val base = serverUrl.trimEnd('/')
|
val base = serverUrl.trimEnd('/')
|
||||||
return when {
|
return when {
|
||||||
name.contains("bitrix", ignoreCase = true) -> "$base/themes/forbion/images/header/bitrix-glass.svg"
|
icon.isNullOrBlank() -> "$base/index.php/apps/external/img/external.svg"
|
||||||
name.contains("1c", ignoreCase = true) -> "$base/themes/forbion/images/header/1c-glass.svg"
|
icon.startsWith("http") -> icon
|
||||||
else -> "$base/index.php/apps/external/img/external.svg"
|
icon.startsWith("/") -> "$base$icon"
|
||||||
|
else -> "$base/index.php/apps/external/img/$icon"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package ru.forbion.f7cloud.mobile.ui
|
|||||||
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
||||||
|
|
||||||
|
// Иконки шторки — ПЛОСКИЕ menu-иконки живой темы forbion (images/menu/*.svg: зелёный+чёрный
|
||||||
|
// контур), а не glass-кружки. Локальные (assets/menu/menu-*.svg) — офлайн и 1:1 с сайтом.
|
||||||
|
private fun menuIcon(name: String): String = "file:///android_asset/menu/menu-$name.svg"
|
||||||
|
|
||||||
enum class AppTab(
|
enum class AppTab(
|
||||||
val title: String,
|
val title: String,
|
||||||
val headerIconPath: String,
|
val headerIconPath: String,
|
||||||
@@ -29,16 +33,18 @@ private data class AppMenuEntry(
|
|||||||
val webPath: String? = null,
|
val webPath: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
|
||||||
|
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
|
||||||
|
// iconPath — базовое имя плоской menu-иконки (assets/menu/menu-<name>.svg).
|
||||||
|
// Состав/порядок 1:1 со шторкой сайта: Файлы·Календарь·Контакты·Карточки·Заметки·Поддержка
|
||||||
|
// (Почта/Задачи/Конференции не тут — они в нижней панели).
|
||||||
private val coreAppMenuEntries = listOf(
|
private val coreAppMenuEntries = listOf(
|
||||||
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
|
AppMenuEntry(AppTab.Files, "Файлы", "files"),
|
||||||
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
|
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
|
||||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
|
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
|
||||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
|
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
|
||||||
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
|
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
|
||||||
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
|
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
|
||||||
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
|
|
||||||
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
|
|
||||||
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppMenuExternalSite(
|
data class AppMenuExternalSite(
|
||||||
@@ -58,7 +64,7 @@ fun appMenuItems(
|
|||||||
val core = coreAppMenuEntries.map { entry ->
|
val core = coreAppMenuEntries.map { entry ->
|
||||||
F7AppMenuItem(
|
F7AppMenuItem(
|
||||||
label = entry.label,
|
label = entry.label,
|
||||||
iconUrl = "$base/themes/forbion/images/header/${entry.iconPath}",
|
iconUrl = menuIcon(entry.iconPath),
|
||||||
selected = entry.tab == active,
|
selected = entry.tab == active,
|
||||||
externalUrl = entry.webPath?.let { "$base$it" },
|
externalUrl = entry.webPath?.let { "$base$it" },
|
||||||
)
|
)
|
||||||
@@ -71,7 +77,26 @@ fun appMenuItems(
|
|||||||
externalUrl = site.openUrl,
|
externalUrl = site.openUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return core + external
|
// Нативные пункты — плоские menu-иконки темы, действия по label в AppScaffold.
|
||||||
|
// Порядок как на сайте: после внешних сайтов — Личный кабинет, затем Уведомления, Настройки.
|
||||||
|
val native = listOf(
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = "Личный кабинет",
|
||||||
|
iconUrl = menuIcon("lk"),
|
||||||
|
selected = false,
|
||||||
|
),
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = "Уведомления",
|
||||||
|
iconUrl = menuIcon("notifications"),
|
||||||
|
selected = false,
|
||||||
|
),
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = "Настройки",
|
||||||
|
iconUrl = menuIcon("settings"),
|
||||||
|
selected = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return core + external + native
|
||||||
}
|
}
|
||||||
|
|
||||||
fun appTabFromMenuIndex(index: Int): AppTab? {
|
fun appTabFromMenuIndex(index: Int): AppTab? {
|
||||||
|
|||||||
@@ -18,28 +18,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
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.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -54,12 +38,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
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.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -69,13 +50,10 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
import ru.forbion.f7cloud.mobile.BuildConfig
|
|
||||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||||
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
|
||||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
import androidx.compose.runtime.collectAsState
|
||||||
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
|
||||||
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||||
import ru.forbion.f7cloud.core.push.F7PushEvent
|
import ru.forbion.f7cloud.core.push.F7PushEvent
|
||||||
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
import ru.forbion.f7cloud.core.push.F7PushEventHub
|
||||||
@@ -88,14 +66,10 @@ import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
|
|||||||
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
import ru.forbion.f7cloud.core.designsystem.F7BottomBarConfig
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
import ru.forbion.f7cloud.core.designsystem.F7MobileBottomBar
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||||
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||||
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
|
import ru.forbion.f7cloud.mobile.OfficeEditorActivity
|
||||||
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
|
||||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||||
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
import ru.forbion.f7cloud.mobile.permissions.F7AppPermissions
|
||||||
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
|
import ru.forbion.f7cloud.mobile.permissions.F7PermissionRationaleDialog
|
||||||
@@ -117,8 +91,8 @@ fun AppScaffold(
|
|||||||
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
|
onRequestRuntimePermissions: (onFinished: (() -> Unit)?) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val authStore = remember { AuthStore(context) }
|
val mainVm: MainViewModel = viewModel()
|
||||||
var session by remember { mutableStateOf(authStore.load()) }
|
val session by mainVm.session.collectAsState()
|
||||||
var activeTab by rememberSaveable(
|
var activeTab by rememberSaveable(
|
||||||
saver = Saver(
|
saver = Saver(
|
||||||
save = { state -> state.value.name },
|
save = { state -> state.value.name },
|
||||||
@@ -173,17 +147,7 @@ fun AppScaffold(
|
|||||||
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
|
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
val logoutScope = rememberCoroutineScope()
|
val forceLogout = { mainVm.logout() }
|
||||||
val forceLogout = {
|
|
||||||
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
|
||||||
session?.let { current ->
|
|
||||||
logoutScope.launch { AppPasswordRevoker.revoke(current) }
|
|
||||||
}
|
|
||||||
OfficeWarmup.clear()
|
|
||||||
OfficeWebViewPool.dispose()
|
|
||||||
authStore.clear()
|
|
||||||
session = null
|
|
||||||
}
|
|
||||||
LaunchedEffect(session?.serverUrl, session?.username) {
|
LaunchedEffect(session?.serverUrl, session?.username) {
|
||||||
session?.let { OfficeWarmup.warm(it) }
|
session?.let { OfficeWarmup.warm(it) }
|
||||||
}
|
}
|
||||||
@@ -196,10 +160,7 @@ fun AppScaffold(
|
|||||||
|
|
||||||
F7Theme {
|
F7Theme {
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
LoginScreen(onLogin = {
|
LoginScreen(onLogin = { mainVm.login(it) })
|
||||||
authStore.save(it)
|
|
||||||
session = it
|
|
||||||
})
|
|
||||||
return@F7Theme
|
return@F7Theme
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +444,7 @@ 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) ||
|
||||||
@@ -496,39 +458,27 @@ fun AppScaffold(
|
|||||||
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
||||||
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
||||||
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
||||||
else -> Unit
|
// Разделы без сайдбара (Карточки/Конференции/Контакты/Задачи/
|
||||||
|
// Поддержка): стрелка = «назад» к предыдущему разделу.
|
||||||
|
else -> popTabHistory()?.let { activeTab = it }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCreateClick = {
|
onMailClick = {
|
||||||
when (activeTab) {
|
if (activeTab != AppTab.Mail) {
|
||||||
AppTab.Files -> {
|
pushTabHistory(activeTab)
|
||||||
if (F7AppPermissions.missing(context).isNotEmpty()) {
|
activeTab = AppTab.Mail
|
||||||
onRequestRuntimePermissions { filesUploadRequest++ }
|
|
||||||
} else {
|
|
||||||
filesUploadRequest++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppTab.Contacts -> contactsCreateRequest++
|
|
||||||
AppTab.Tasks -> tasksCreateRequest++
|
|
||||||
AppTab.Support -> supportCreateRequest++
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onProfileClick = {
|
onTasksClick = {
|
||||||
menuOpen = false
|
if (activeTab != AppTab.Tasks) {
|
||||||
profileOpen = true
|
pushTabHistory(activeTab)
|
||||||
|
activeTab = AppTab.Tasks
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onNotificationsClick = {
|
onConferencesClick = {
|
||||||
menuOpen = false
|
if (activeTab != AppTab.Talk) {
|
||||||
hasNotificationBadge = false
|
pushTabHistory(activeTab)
|
||||||
notificationsOpen = true
|
activeTab = AppTab.Talk
|
||||||
},
|
|
||||||
onSettingsClick = {
|
|
||||||
when (activeTab) {
|
|
||||||
AppTab.Mail -> mailSettingsOpen = true
|
|
||||||
AppTab.Calendar -> calendarSettingsRequest++
|
|
||||||
AppTab.Files -> filesSettingsOpen = true
|
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMenuClick = { menuOpen = !menuOpen },
|
onMenuClick = { menuOpen = !menuOpen },
|
||||||
@@ -637,19 +587,43 @@ 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
|
||||||
if (!external.isNullOrBlank()) {
|
val label = item.label.trim()
|
||||||
runCatching {
|
when {
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
// Профиль — нативная панель вместо веб-ЛК forbion
|
||||||
|
label.equals("Личный кабинет", ignoreCase = true) -> {
|
||||||
|
menuOpen = false
|
||||||
|
profileOpen = true
|
||||||
}
|
}
|
||||||
menuOpen = false
|
// Нативные пункты по дизайну меню
|
||||||
} else {
|
label.equals("Уведомления", ignoreCase = true) -> {
|
||||||
appTabFromMenuIndex(index)?.let { tab ->
|
menuOpen = false
|
||||||
if (tab != activeTab) {
|
hasNotificationBadge = false
|
||||||
pushTabHistory(activeTab)
|
notificationsOpen = true
|
||||||
activeTab = tab
|
}
|
||||||
|
label.equals("Настройки", ignoreCase = true) -> {
|
||||||
|
menuOpen = false
|
||||||
|
when (activeTab) {
|
||||||
|
AppTab.Mail -> mailSettingsOpen = true
|
||||||
|
AppTab.Calendar -> calendarSettingsRequest++
|
||||||
|
AppTab.Files -> filesSettingsOpen = true
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menuOpen = false
|
!external.isNullOrBlank() -> {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||||
|
}
|
||||||
|
menuOpen = false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
appTabFromMenuIndex(index)?.let { tab ->
|
||||||
|
if (tab != activeTab) {
|
||||||
|
pushTabHistory(activeTab)
|
||||||
|
activeTab = tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menuOpen = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -677,254 +651,3 @@ fun AppScaffold(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
|
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
|
||||||
@Composable
|
|
||||||
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val serverFocus = remember { FocusRequester() }
|
|
||||||
val usernameFocus = remember { FocusRequester() }
|
|
||||||
val passwordFocus = remember { FocusRequester() }
|
|
||||||
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
|
||||||
var username by rememberSaveable { mutableStateOf("") }
|
|
||||||
var password by rememberSaveable { mutableStateOf("") }
|
|
||||||
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var loading by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val qrLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
|
||||||
) { result ->
|
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
|
||||||
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
|
||||||
?: return@rememberLauncherForActivityResult
|
|
||||||
scope.launch {
|
|
||||||
loading = true
|
|
||||||
error = null
|
|
||||||
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
|
||||||
if (loginResult == null) {
|
|
||||||
error = if (qrData.contains("/login/v2/flow/")) {
|
|
||||||
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
|
||||||
} else {
|
|
||||||
"Не удалось распознать QR-код"
|
|
||||||
}
|
|
||||||
loading = false
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val newSession = AuthSession(
|
|
||||||
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
|
||||||
username = loginResult.username,
|
|
||||||
appPassword = loginResult.appPassword,
|
|
||||||
trustAllCerts = trustAllCerts,
|
|
||||||
)
|
|
||||||
AuthVerifier.verify(newSession)
|
|
||||||
.onSuccess { verified -> onLogin(verified) }
|
|
||||||
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun launchQrScan() {
|
|
||||||
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
android.Manifest.permission.CAMERA,
|
|
||||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
|
||||||
} else {
|
|
||||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submitLogin() {
|
|
||||||
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val newSession = AuthSession(
|
|
||||||
serverUrl = normalizeServerUrl(serverUrl),
|
|
||||||
username = username.trim(),
|
|
||||||
appPassword = password,
|
|
||||||
trustAllCerts = trustAllCerts,
|
|
||||||
)
|
|
||||||
scope.launch {
|
|
||||||
loading = true
|
|
||||||
error = null
|
|
||||||
AuthVerifier.verify(newSession)
|
|
||||||
.onSuccess { verified -> onLogin(verified) }
|
|
||||||
.onFailure { error = it.message ?: "Ошибка входа" }
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val logoUrl = remember(serverUrl) {
|
|
||||||
"${normalizeServerUrl(serverUrl).trimEnd('/')}/themes/forbion/images/login/big-forbion.svg"
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.f7SafeTopInsets()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 440.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = logoUrl,
|
|
||||||
contentDescription = "Forbion",
|
|
||||||
modifier = Modifier
|
|
||||||
.width(300.dp)
|
|
||||||
.height(70.dp)
|
|
||||||
.padding(bottom = 48.dp),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
color = F7Colors.Background,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Вход",
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
color = F7Colors.TextPrimary,
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
|
||||||
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = F7Colors.TextSecondary,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
|
||||||
F7OutlinedField(
|
|
||||||
value = serverUrl,
|
|
||||||
onValueChange = { serverUrl = it },
|
|
||||||
label = "Адрес сервера",
|
|
||||||
modifier = Modifier
|
|
||||||
.focusRequester(serverFocus)
|
|
||||||
.focusProperties { next = usernameFocus },
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Uri,
|
|
||||||
imeAction = ImeAction.Next,
|
|
||||||
autoCorrectEnabled = false,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { usernameFocus.requestFocus() },
|
|
||||||
),
|
|
||||||
onEnter = { usernameFocus.requestFocus() },
|
|
||||||
)
|
|
||||||
F7OutlinedField(
|
|
||||||
value = username,
|
|
||||||
onValueChange = { username = it },
|
|
||||||
label = "Имя пользователя",
|
|
||||||
modifier = Modifier
|
|
||||||
.focusRequester(usernameFocus)
|
|
||||||
.focusProperties { next = passwordFocus },
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Text,
|
|
||||||
imeAction = ImeAction.Next,
|
|
||||||
autoCorrectEnabled = false,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { passwordFocus.requestFocus() },
|
|
||||||
),
|
|
||||||
onEnter = { passwordFocus.requestFocus() },
|
|
||||||
)
|
|
||||||
F7OutlinedField(
|
|
||||||
value = password,
|
|
||||||
onValueChange = { password = it },
|
|
||||||
label = "Пароль",
|
|
||||||
modifier = Modifier.focusRequester(passwordFocus),
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
autoCorrectEnabled = false,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
submitLogin()
|
|
||||||
},
|
|
||||||
onGo = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
submitLogin()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onEnter = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
submitLogin()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// Чекбокс trust-all показываем только в debug-сборках: в release он
|
|
||||||
// всё равно игнорируется на уровне сети/WebView (см. UnsafeSsl).
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Checkbox(
|
|
||||||
checked = trustAllCerts,
|
|
||||||
onCheckedChange = { trustAllCerts = it },
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Доверять сертификату (dev)",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = F7Colors.TextSecondary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
F7PrimaryButton(
|
|
||||||
text = if (loading) "…" else "Войти",
|
|
||||||
onClick = { submitLogin() },
|
|
||||||
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
F7TextButton(
|
|
||||||
text = "Сканировать QR для входа",
|
|
||||||
onClick = { launchQrScan() },
|
|
||||||
enabled = !loading,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
if (loading) {
|
|
||||||
CircularProgressIndicator(color = F7Colors.Primary)
|
|
||||||
}
|
|
||||||
if (error != null) {
|
|
||||||
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
F7TextButton(
|
|
||||||
text = "Очистить",
|
|
||||||
onClick = {
|
|
||||||
error = null
|
|
||||||
password = ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
package ru.forbion.f7cloud.mobile.ui
|
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
|
||||||
@@ -21,22 +42,30 @@ 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"
|
||||||
@@ -54,46 +83,178 @@ 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,
|
||||||
) {
|
) {
|
||||||
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка QR
|
||||||
Text(
|
Row(
|
||||||
text = session.username,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
color = F7Colors.TextPrimary,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
)
|
) {
|
||||||
Text(
|
Box(
|
||||||
text = session.serverUrl,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodySmall,
|
.size(44.dp)
|
||||||
color = F7Colors.TextSecondary,
|
.clip(CircleShape)
|
||||||
)
|
.background(F7Colors.PrimaryLight),
|
||||||
session.davUserId?.let { davId ->
|
contentAlignment = Alignment.Center,
|
||||||
Text(
|
) {
|
||||||
text = "ID: $davId",
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
|
||||||
color = F7Colors.TextSecondary,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.Primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { openWeb("/u/$userId") }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
displayName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Открыть профиль",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = F7Colors.Primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(F7Colors.Grey2)
|
||||||
|
.clickable {
|
||||||
|
onDismiss()
|
||||||
|
onScanBrowserQr()
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.QrCodeScanner,
|
||||||
|
contentDescription = "Сканировать QR браузера",
|
||||||
|
tint = F7Colors.TextSecondary,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
ProfileMenuRow(Icons.Filled.TaskAlt, "Установить статус") { openWeb("/settings/user") }
|
||||||
|
ProfileMenuRow(Icons.Filled.Person, "Личные настройки") { openWeb("/settings/user") }
|
||||||
|
ProfileMenuRow(Icons.Filled.Group, "Учётные записи") { openWeb("/settings/user/security") }
|
||||||
|
ProfileMenuRow(Icons.Outlined.Info, "О программе и что нового") { aboutOpen = true }
|
||||||
|
ProfileMenuRow(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Logout,
|
||||||
|
label = "Выйти",
|
||||||
|
tint = F7Colors.Error,
|
||||||
|
) {
|
||||||
|
onDismiss()
|
||||||
|
onLogout()
|
||||||
}
|
}
|
||||||
F7PrimaryButton(
|
|
||||||
text = "Сканировать QR браузера",
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
onScanBrowserQr()
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
F7PrimaryButton(
|
|
||||||
text = "Выйти",
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
onLogout()
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aboutOpen) {
|
||||||
|
F7AboutDialog(onDismiss = { aboutOpen = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileMenuRow(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
tint: Color = F7Colors.TextPrimary,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge, color = tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun F7AboutDialog(onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val version = remember {
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull() ?: "—"
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Закрыть") }
|
||||||
|
},
|
||||||
|
title = { Text("F7cloud Mobile") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Версия $version", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Нативный клиент F7cloud: почта, файлы, календарь, контакты, задачи, конференции, поддержка.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отображаемое имя пользователя из OCS (fallback — пусто). */
|
||||||
|
private suspend fun fetchDisplayName(session: AuthSession): String = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val client = NetworkFactory.newAuthedClient(
|
||||||
|
session.username,
|
||||||
|
session.appPassword,
|
||||||
|
session.trustAllCerts,
|
||||||
|
)
|
||||||
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
|
||||||
|
val request = Request.Builder().url(url).applyOcsJson().get().build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
JSONObject(body)
|
||||||
|
.optJSONObject("ocs")
|
||||||
|
?.optJSONObject("data")
|
||||||
|
?.optString("displayname")
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
}.getOrDefault("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthVerifier
|
||||||
|
import ru.forbion.f7cloud.core.auth.normalizeServerUrl
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||||
|
import ru.forbion.f7cloud.core.network.LoginFlowClient
|
||||||
|
import ru.forbion.f7cloud.mobile.BuildConfig
|
||||||
|
import ru.forbion.f7cloud.mobile.qr.F7QrScannerActivity
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LoginScreen(onLogin: (AuthSession) -> Unit) {
|
||||||
|
F7SecureScreen() // ввод пароля — не в скриншотах/recents
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val serverFocus = remember { FocusRequester() }
|
||||||
|
val usernameFocus = remember { FocusRequester() }
|
||||||
|
val passwordFocus = remember { FocusRequester() }
|
||||||
|
var serverUrl by rememberSaveable { mutableStateOf(BuildConfig.DEFAULT_SERVER_URL) }
|
||||||
|
var username by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var trustAllCerts by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var loading by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val qrLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||||
|
val qrData = result.data?.getStringExtra(F7QrScannerActivity.RESULT_EXTRA)
|
||||||
|
?: return@rememberLauncherForActivityResult
|
||||||
|
scope.launch {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
val loginResult = LoginFlowClient.completeQrLogin(qrData, trustAllCerts)
|
||||||
|
if (loginResult == null) {
|
||||||
|
error = if (qrData.contains("/login/v2/flow/")) {
|
||||||
|
"Для входа в браузер откройте профиль в приложении и выберите «Сканировать QR браузера»"
|
||||||
|
} else {
|
||||||
|
"Не удалось распознать QR-код"
|
||||||
|
}
|
||||||
|
loading = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val newSession = AuthSession(
|
||||||
|
serverUrl = normalizeServerUrl(loginResult.serverUrl),
|
||||||
|
username = loginResult.username,
|
||||||
|
appPassword = loginResult.appPassword,
|
||||||
|
trustAllCerts = trustAllCerts,
|
||||||
|
)
|
||||||
|
AuthVerifier.verify(newSession)
|
||||||
|
.onSuccess { verified -> onLogin(verified) }
|
||||||
|
.onFailure { error = it.message ?: "Ошибка входа по QR" }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchQrScan() {
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.CAMERA,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
qrLauncher.launch(F7QrScannerActivity.intent(context))
|
||||||
|
} else {
|
||||||
|
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitLogin() {
|
||||||
|
if (loading || serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val newSession = AuthSession(
|
||||||
|
serverUrl = normalizeServerUrl(serverUrl),
|
||||||
|
username = username.trim(),
|
||||||
|
appPassword = password,
|
||||||
|
trustAllCerts = trustAllCerts,
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
AuthVerifier.verify(newSession)
|
||||||
|
.onSuccess { verified -> onLogin(verified) }
|
||||||
|
.onFailure { error = it.message ?: "Ошибка входа" }
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логотип — локальный (assets/login/big-forbion.svg из живой темы): виден офлайн и до
|
||||||
|
// ввода адреса сервера.
|
||||||
|
val logoUrl = "file:///android_asset/login/big-forbion.svg"
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Живая тема логина: фон страницы --backgroud-color-main-darkgray
|
||||||
|
.background(Color(0xFFE5EFE8))
|
||||||
|
.f7SafeTopInsets()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 440.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = logoUrl,
|
||||||
|
contentDescription = "Forbion",
|
||||||
|
modifier = Modifier
|
||||||
|
.width(300.dp)
|
||||||
|
.height(70.dp)
|
||||||
|
.padding(bottom = 48.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = F7Colors.Background,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Вход",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Используйте тот же пароль, что и для входа в веб-интерфейс. " +
|
||||||
|
"Если включена двухфакторная аутентификация — нужен пароль приложения.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
F7OutlinedField(
|
||||||
|
value = serverUrl,
|
||||||
|
onValueChange = { serverUrl = it },
|
||||||
|
label = "Адрес сервера",
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(serverFocus)
|
||||||
|
.focusProperties { next = usernameFocus },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { usernameFocus.requestFocus() },
|
||||||
|
),
|
||||||
|
onEnter = { usernameFocus.requestFocus() },
|
||||||
|
)
|
||||||
|
F7OutlinedField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = "Имя пользователя",
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(usernameFocus)
|
||||||
|
.focusProperties { next = passwordFocus },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { passwordFocus.requestFocus() },
|
||||||
|
),
|
||||||
|
onEnter = { passwordFocus.requestFocus() },
|
||||||
|
)
|
||||||
|
F7OutlinedField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = "Пароль",
|
||||||
|
modifier = Modifier.focusRequester(passwordFocus),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
submitLogin()
|
||||||
|
},
|
||||||
|
onGo = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
submitLogin()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onEnter = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
submitLogin()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = if (loading) "…" else "Войти",
|
||||||
|
onClick = { submitLogin() },
|
||||||
|
enabled = !loading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
// Живая тема: вторичная кнопка-ссылка 40dp, фон #F0F1F4, r8, текст 14 чёрный
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color(0xFFF0F1F4))
|
||||||
|
.clickable(enabled = !loading, onClick = { launchQrScan() }),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Сканировать QR для входа",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator(color = F7Colors.Primary)
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
Text(text = error ?: "", color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
F7TextButton(
|
||||||
|
text = "Очистить",
|
||||||
|
onClick = {
|
||||||
|
error = null
|
||||||
|
password = ""
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||||
|
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||||
|
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Держит session/auth-жизненный цикл верхнего уровня (вынесено из AppScaffold).
|
||||||
|
* В ViewModel сессия переживает пересоздание Activity (поворот и т.п.) — раньше
|
||||||
|
* жила в remember и перечитывалась из AuthStore.
|
||||||
|
*/
|
||||||
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
private val authStore = AuthStore(app)
|
||||||
|
private val _session = MutableStateFlow(authStore.load())
|
||||||
|
val session: StateFlow<AuthSession?> = _session.asStateFlow()
|
||||||
|
|
||||||
|
fun login(newSession: AuthSession) {
|
||||||
|
authStore.save(newSession)
|
||||||
|
_session.value = newSession
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
||||||
|
_session.value?.let { current ->
|
||||||
|
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
|
||||||
|
}
|
||||||
|
OfficeWarmup.clear()
|
||||||
|
OfficeWebViewPool.dispose()
|
||||||
|
authStore.clear()
|
||||||
|
_session.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Логотип F7 для сплэша: вектор из темы forbion (logo-header.svg) вместо растянутого
|
||||||
|
растра лаунчер-иконки (мыло). Глиф (35×32, контент x 2.26–33.53 / y 5.01–25.99)
|
||||||
|
отцентрирован в центральных 2/3 квадрата 48 — безопасная зона круглой маски сплэша. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<group
|
||||||
|
android:scaleX="1.0233"
|
||||||
|
android:scaleY="1.0233"
|
||||||
|
android:translateX="5.69"
|
||||||
|
android:translateY="8.14">
|
||||||
|
<path
|
||||||
|
android:fillColor="#151515"
|
||||||
|
android:pathData="M8.33398 18.9808V25.9711C8.33398 25.9761 8.33209 25.981 8.32873 25.9845C8.32537 25.9881 8.3208 25.9901 8.31605 25.9901H2.27416C2.2718 25.9901 2.26947 25.9896 2.2673 25.9887C2.26512 25.9877 2.26314 25.9863 2.26148 25.9845C2.25812 25.981 2.25623 25.9761 2.25623 25.9711V5.02973C2.25623 5.02724 2.25669 5.02477 2.25759 5.02246C2.25849 5.02016 2.25981 5.01807 2.26148 5.0163C2.26484 5.01274 2.2694 5.01074 2.27416 5.01074H19.7382C19.7415 5.01074 19.7447 5.01166 19.7475 5.01343C19.7503 5.0152 19.7526 5.01773 19.7541 5.02076C19.7556 5.0238 19.7564 5.02722 19.7562 5.03065C19.756 5.03409 19.755 5.03742 19.7532 5.04028L16.2595 10.7647C16.2578 10.7674 16.2555 10.7696 16.2528 10.771C16.2501 10.7725 16.2471 10.7732 16.244 10.7731H8.35192C8.34716 10.7731 8.3426 10.7751 8.33924 10.7787C8.33587 10.7823 8.33398 10.7871 8.33398 10.7921V13.1672C8.33398 13.1723 8.33587 13.1771 8.33924 13.1807C8.3426 13.1842 8.34716 13.1862 8.35192 13.1862H14.9303C14.9328 13.1862 14.9352 13.1867 14.9374 13.1878C14.9397 13.1888 14.9417 13.1903 14.9434 13.1922C14.9451 13.194 14.9464 13.1963 14.9472 13.1987C14.948 13.2011 14.9484 13.2037 14.9483 13.2063L14.6937 18.9439C14.6935 18.9487 14.6915 18.9533 14.6881 18.9566C14.6848 18.96 14.6804 18.9618 14.6758 18.9618H8.35192C8.34716 18.9618 8.3426 18.9638 8.33924 18.9674C8.33587 18.9709 8.33398 18.9758 8.33398 18.9808Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#70B62B"
|
||||||
|
android:pathData="M26.2675 10.8048C26.2877 10.7837 26.2836 10.7731 26.255 10.7731H17.7975C17.7762 10.7731 17.7714 10.7638 17.783 10.7452L21.3221 5.03395C21.3317 5.01848 21.3453 5.01074 21.3629 5.01074H33.5155C33.5188 5.01074 33.5219 5.01213 33.5243 5.0146C33.5266 5.01708 33.5279 5.02043 33.5279 5.02393V10.7494C33.5279 10.7673 33.5218 10.7826 33.5095 10.7953C31.0839 13.3392 28.8027 16.2518 27.1169 19.4365C26.3193 20.944 25.6677 22.6434 25.4834 24.3571C25.4296 24.8592 25.4445 25.4268 25.445 25.9632C25.445 25.9811 25.4367 25.9901 25.4201 25.9901H18.4337C18.4164 25.9901 18.4072 25.981 18.4063 25.9627C18.3571 24.9535 18.4315 23.9955 18.6294 23.0886C19.0753 21.0468 20.0567 19.0346 21.1572 17.2724C22.6248 14.9221 24.3809 12.7806 26.2675 10.8048Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Сплэш при холодном старте (androidx core-splashscreen): фон в цвет
|
||||||
|
F7Colors.Background + векторный логотип F7 (лаунчер-иконка — растянутый
|
||||||
|
растр, мылится на сплэше); после — прежняя тема. -->
|
||||||
|
<style name="Theme.F7.Splash" parent="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">#FBFBFB</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/f7_splash_logo</item>
|
||||||
|
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -8,6 +8,23 @@ 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 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
|
||||||
@@ -21,5 +21,5 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':core:network')
|
implementation project(':core:network')
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation libs.coroutines.android
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?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>
|
||||||
@@ -18,6 +18,8 @@ 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")
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ 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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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')
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest />
|
|
||||||
@@ -21,7 +21,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api 'androidx.room:room-runtime:2.7.2'
|
api libs.room.runtime
|
||||||
implementation 'androidx.room:room-ktx:2.7.2'
|
implementation libs.room.ktx
|
||||||
ksp 'androidx.room:room-compiler:2.7.2'
|
ksp libs.room.compiler
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?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>
|
||||||
@@ -25,11 +25,11 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
def composeBom = platform(libs.compose.bom)
|
||||||
implementation composeBom
|
implementation composeBom
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation libs.compose.ui
|
||||||
implementation 'androidx.compose.foundation:foundation'
|
implementation libs.compose.foundation
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation libs.compose.material3
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?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: () -> Unit</ID>
|
||||||
|
<ID>UnusedParameter:F7MobileBottomBar.kt$userId: String</ID>
|
||||||
|
</CurrentIssues>
|
||||||
|
</SmellBaseline>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1460_27244)">
|
||||||
|
<path d="M3 3H13.5341" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 8H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 13H13.5" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1460_27244">
|
||||||
|
<rect width="16" height="16" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 570 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
|
||||||
|
<path d="M1 26.2893V9H29V26.2893C29 27.7864 27.7864 29 26.2893 29H3.71067C2.21361 29 1 27.7864 1 26.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||||
|
<path d="M1 9V5.71067C1 4.21361 2.21361 3 3.71067 3H26.2893C27.7864 3 29 4.21361 29 5.71067V9H1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||||
|
<path d="M6.32233 1H5.67767C5.3034 1 5 1.3034 5 1.67767V5.32233C5 5.6966 5.3034 6 5.67767 6H6.32233C6.6966 6 7 5.6966 7 5.32233V1.67767C7 1.3034 6.6966 1 6.32233 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||||
|
<path d="M12.3223 1H11.6777C11.3034 1 11 1.3034 11 1.67767V5.32233C11 5.6966 11.3034 6 11.6777 6H12.3223C12.6966 6 13 5.6966 13 5.32233V1.67767C13 1.3034 12.6966 1 12.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||||
|
<path d="M18.3223 1H17.6777C17.3034 1 17 1.3034 17 1.67767V5.32233C17 5.6966 17.3034 6 17.6777 6H18.3223C18.6966 6 19 5.6966 19 5.32233V1.67767C19 1.3034 18.6966 1 18.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||||
|
<path d="M24.3223 1H23.6777C23.3034 1 23 1.3034 23 1.67767V5.32233C23 5.6966 23.3034 6 23.6777 6H24.3223C24.6966 6 25 5.6966 25 5.32233V1.67767C25 1.3034 24.6966 1 24.3223 1Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555" />
|
||||||
|
<path
|
||||||
|
d="M14.5436 21.844V23H10.1415V21.844H11.7412V15.9356C11.679 16.029 11.5739 16.1341 11.426 16.2509C11.2858 16.3598 11.1263 16.4688 10.9472 16.5778C10.7682 16.679 10.5813 16.7646 10.3867 16.8347C10.1999 16.9048 10.0287 16.9398 9.87296 16.9398V15.7488C10.0831 15.7488 10.2933 15.6943 10.5035 15.5853C10.7215 15.4763 10.9239 15.3517 11.1107 15.2116C11.2975 15.0637 11.4454 14.9314 11.5544 14.8146C11.6712 14.6979 11.7335 14.6356 11.7412 14.6278H13.0607V21.844H14.5436ZM18.0332 23.0934C17.5739 23.0934 17.1535 23.0272 16.7721 22.8949C16.3984 22.7626 16.0832 22.5719 15.8263 22.3228C15.5694 22.0736 15.3825 21.7856 15.2658 21.4587L15.9664 20.6179C16.0209 20.8203 16.1338 21.0227 16.305 21.2251C16.4841 21.4275 16.7137 21.591 16.9939 21.7156C17.282 21.8401 17.6167 21.9024 17.9981 21.9024C18.3329 21.9024 18.6287 21.8518 18.8856 21.7506C19.1502 21.6416 19.3604 21.4898 19.5161 21.2952C19.6718 21.1006 19.7496 20.8709 19.7496 20.6063C19.7496 20.3105 19.6562 20.0614 19.4694 19.859C19.2826 19.6566 19.014 19.5048 18.6637 19.4036C18.3134 19.3024 17.893 19.2518 17.4026 19.2518H17.0056V18.2242H17.4026C18.0487 18.2242 18.5625 18.1191 18.9439 17.909C19.3332 17.6988 19.5278 17.3874 19.5278 16.9748C19.5278 16.7179 19.4577 16.4961 19.3176 16.3092C19.1775 16.1146 18.9945 15.9706 18.7688 15.8772C18.543 15.776 18.2939 15.7254 18.0215 15.7254C17.5778 15.7254 17.1924 15.8266 16.8655 16.029C16.5385 16.2314 16.3011 16.4805 16.1532 16.7763L15.3125 15.8538C15.476 15.597 15.7017 15.3751 15.9897 15.1883C16.2778 15.0014 16.6008 14.8574 16.9589 14.7562C17.3248 14.655 17.6945 14.6044 18.0682 14.6044C18.6131 14.6044 19.0996 14.7017 19.5278 14.8964C19.9559 15.091 20.2907 15.3595 20.532 15.7021C20.7733 16.0368 20.894 16.4182 20.894 16.8464C20.894 17.1577 20.8278 17.438 20.6955 17.6871C20.5709 17.9362 20.388 18.1464 20.1466 18.3176C19.9131 18.4811 19.6329 18.6018 19.3059 18.6796C19.6562 18.7419 19.9637 18.8742 20.2284 19.0766C20.5008 19.279 20.7071 19.5281 20.8473 19.8239C20.9952 20.1197 21.0691 20.4428 21.0691 20.7931C21.0691 21.2757 20.929 21.6883 20.6487 22.0308C20.3763 22.3733 20.0104 22.638 19.5511 22.8248C19.0919 23.0039 18.5859 23.0934 18.0332 23.0934Z"
|
||||||
|
fill="#70B62B"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="27" viewBox="0 0 28 27"
|
||||||
|
fill="none">
|
||||||
|
<g clip-path="url(#clip0_4701_11222)">
|
||||||
|
<rect width="28" height="27" fill="white"/>
|
||||||
|
<path d="M1 23.2893V3.71C1 2.21331 2.21331 1 3.71 1H24.29C25.7867 1 27 2.21331 27 3.71V23.2893C27 24.7864 25.7864 26 24.2893 26H3.71067C2.21361 26 1 24.7864 1 23.2893Z"
|
||||||
|
fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
|
||||||
|
<path d="M3.5 6L8.5 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
|
||||||
|
<path d="M19.5 6L24.5756 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
|
||||||
|
<path d="M11.5 6L16.681 6" stroke="#70B62B" stroke-width="0.9" stroke-linecap="round"/>
|
||||||
|
<rect x="3.45" y="20.55" width="12.1" height="5.1" rx="0.55" transform="rotate(-90 3.45 20.55)" stroke="black"
|
||||||
|
stroke-width="0.9"/>
|
||||||
|
<rect x="19.45" y="20.55" width="12.1" height="5.1" rx="0.55" transform="rotate(-90 19.45 20.55)" stroke="black"
|
||||||
|
stroke-width="0.9"/>
|
||||||
|
<rect x="11.45" y="13.55" width="5.1" height="5.1" rx="0.55" transform="rotate(-90 11.45 13.55)" stroke="black"
|
||||||
|
stroke-width="0.9"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_4701_11222">
|
||||||
|
<rect width="28" height="27" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="29" height="30" viewBox="0 0 29 30" fill="none">
|
||||||
|
<path
|
||||||
|
d="M28 14.5063V3.86241C28 3.14045 27.1472 2.75719 26.6073 3.23654L19.5114 9.53691C19.0911 9.91008 19.1508 10.5832 19.6301 10.8766L26.7261 15.2202C27.2838 15.5616 28 15.1602 28 14.5063Z"
|
||||||
|
fill="#F5F5F5"
|
||||||
|
stroke="black"
|
||||||
|
stroke-width="0.836976"
|
||||||
|
/>
|
||||||
|
<path d="M0 2.51093C0 1.12418 1.12418 0 2.51093 0H18.4891C19.8758 0 21 1.12418 21 2.51093V11V15.4891C21 16.8758 19.8758 18 18.4891 18H2.51093C1.12418 18 0 16.8758 0 15.4891V2.51093Z" fill="#F5F5F5" />
|
||||||
|
<path
|
||||||
|
d="M0.418488 2.51093C0.418488 1.35531 1.3553 0.418488 2.51093 0.418488H18.4891C19.6447 0.418488 20.5815 1.3553 20.5815 2.51093V11V15.4891C20.5815 16.6447 19.6447 17.5815 18.4891 17.5815H2.51093C1.35531 17.5815 0.418488 16.6447 0.418488 15.4891V2.51093Z"
|
||||||
|
stroke="black"
|
||||||
|
stroke-width="0.836976"
|
||||||
|
/>
|
||||||
|
<line x1="2.33337" y1="12.536" x2="17.8889" y2="12.536" stroke="#70B62B" stroke-width="1.67395" />
|
||||||
|
<rect x="17.1111" y="3.08606" width="1.55556" height="2.05738" fill="#70B62B" />
|
||||||
|
<path d="M0.941024 25.501L7.49997 17.6557L10.3173 17.7274L2.57507 27.2897C2.23834 27.7056 1.68223 27.7818 1.23338 27.4736C0.606407 27.043 0.462663 26.0731 0.941024 25.501Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" />
|
||||||
|
<path d="M18.5966 27.3311L10.6298 17.6627L13.6298 17.6627L20.4548 26.2201C20.7632 26.6068 20.7068 27.1272 20.3206 27.4581C19.8242 27.8833 19.0017 27.8227 18.5966 27.3311Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="29" viewBox="0 0 26 29" fill="none">
|
||||||
|
<path d="M4 25.2893V3.71C4 2.21331 5.21331 1 6.71 1H22.29C23.7867 1 25 2.21331 25 3.71V25.2893C25 26.7864 23.7864 28 22.2893 28H6.71067C5.21361 28 4 26.7864 4 25.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||||
|
<path d="M4 26V3C4 1.89543 4.89543 1 6 1H8V28H6C4.89543 28 4 27.1046 4 26Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||||
|
<path d="M1.16211 13.6755V14.3201C1.16211 14.6944 1.46551 14.9978 1.83978 14.9978H5.48444C5.85871 14.9978 6.16211 14.6944 6.16211 14.3201V13.6755C6.16211 13.3012 5.85871 12.9978 5.48444 12.9978H1.83978C1.46551 12.9978 1.16211 13.3012 1.16211 13.6755Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||||
|
<path d="M1.16211 19.6777V20.3223C1.16211 20.6966 1.46551 21 1.83978 21H5.48444C5.85871 21 6.16211 20.6966 6.16211 20.3223V19.6777C6.16211 19.3034 5.85871 19 5.48444 19H1.83978C1.46551 19 1.16211 19.3034 1.16211 19.6777Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||||
|
<path d="M1 7.67767V8.32233C1 8.6966 1.3034 9 1.67767 9H5.32233C5.6966 9 6 8.6966 6 8.32233V7.67767C6 7.3034 5.6966 7 5.32233 7L1.67767 7C1.3034 7 1 7.3034 1 7.67767Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||||
|
<circle cx="16.5" cy="12.4375" r="2.0625" fill="#70B62B"></circle>
|
||||||
|
<path d="M16.5 15.1875C14.1966 15.1875 12.8578 16.9963 12.4838 18.0888C12.3854 18.3761 12.6212 18.625 12.925 18.625H20.075C20.3788 18.625 20.6146 18.3761 20.5162 18.0888C20.1422 16.9963 18.8034 15.1875 16.5 15.1875Z" fill="#70B62B"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
|
||||||
|
<path
|
||||||
|
d="M28 12.6762V19.3238C28 20.8018 26.8018 22 25.3237 22H3.67625C2.1982 22 1 20.8018 1 19.3238V9.67625C1 8.1982 2.1982 7 3.67625 7H14.8915C15.6012 7 16.282 7.28196 16.7839 7.78386L18.2161 9.21614C18.718 9.71804 19.3988 10 20.1085 10H25.3238C26.8018 10 28 11.1982 28 12.6762Z"
|
||||||
|
fill="#F5F5F5"
|
||||||
|
stroke="black"
|
||||||
|
stroke-width="0.892083"
|
||||||
|
/>
|
||||||
|
<path d="M5 1V22H25V1H5Z" fill="#F5F5F5" stroke="black" stroke-width="0.892083" />
|
||||||
|
<path d="M4 3V25H23V3H4Z" fill="#FCFEFC" stroke="black" stroke-width="0.892083" />
|
||||||
|
<path
|
||||||
|
d="M1 17.6762V26.3238C1 27.8018 2.1982 29 3.67625 29H26.3237C27.8018 29 29 27.8018 29 26.3238V14.6762C29 13.1982 27.8018 12 26.3238 12H12.7791C12.0774 12 11.4038 12.2756 10.9033 12.7675L9.41252 14.2325C8.91203 14.7244 8.23837 15 7.53665 15H3.67625C2.1982 15 1 16.1982 1 17.6762Z"
|
||||||
|
fill="#F5F5F5"
|
||||||
|
stroke="black"
|
||||||
|
stroke-width="0.892083"
|
||||||
|
/>
|
||||||
|
<path d="M6 6H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
|
||||||
|
<path d="M6 8H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
|
||||||
|
<path d="M6 10H21" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_205_498)">
|
||||||
|
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#e5efe8"/>
|
||||||
|
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
|
||||||
|
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
|
||||||
|
<path d="M15.7661 32.1765L16.9463 25.8847C16.9619 25.8019 17.0341 25.7419 17.1183 25.7419H17.7667C17.8518 25.7419 17.9246 25.8032 17.9391 25.8871L19.0289 32.1808C19.0379 32.2323 19.0233 32.285 18.9893 32.3247L17.5726 33.9747C17.504 34.0545 17.3811 34.0563 17.3103 33.9784L15.8086 32.3265C15.7717 32.2859 15.7559 32.2304 15.7661 32.1765Z" fill="#70B62B"/>
|
||||||
|
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
|
||||||
|
<rect x="22.451" y="12.5238" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5238)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_205_498">
|
||||||
|
<rect width="35" height="35" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="31" height="25" viewBox="0 0 31 25" fill="none">
|
||||||
|
<path d="M1 3.33195V22.2722C1 22.8812 1.36501 23.4309 1.92631 23.6671C2.11218 23.7454 2.31181 23.7857 2.51347 23.7857H14.2872H21.3335H28.1001C28.2854 23.7857 28.4696 23.7573 28.6463 23.7015L28.7223 23.6775C29.4771 23.4392 29.9903 22.7391 29.9903 21.9476V3.33195C29.9903 2.33004 29.1781 1.51782 28.1761 1.51782H15.4951H2.81413C1.81221 1.51782 1 2.33004 1 3.33195Z" fill="#F5F5F5" stroke="black" stroke-width="0.907066"/>
|
||||||
|
<path d="M29.5785 20.5535L17.4516 8.17127C16.3816 7.07877 14.6218 7.08216 13.5561 8.17876L1.75937 20.3169C1.27243 20.8179 1.00003 21.4891 1.00003 22.1877V22.6527C1.00003 23.0415 1.1994 23.4031 1.52818 23.6107C1.70922 23.725 1.91896 23.7857 2.13308 23.7857H3.41589L8.16288 23.7857L21.7535 23.7857L28.3797 23.7857C30.0271 23.7857 30.4021 21.643 29.5785 20.5535Z" stroke="black" stroke-width="0.907066"/>
|
||||||
|
<path d="M1.63846 4.39857L14.8989 16.0094C15.2425 16.3102 15.7562 16.3087 16.098 16.0058L29.54 4.09441C29.8325 3.83517 30 3.46302 30 3.07214C30 2.7761 29.9038 2.48808 29.7259 2.25144L29.582 2.06001C29.33 1.72469 28.9826 1.47328 28.5853 1.33869L28.0101 1.14386C27.7289 1.0486 27.434 1 27.1371 1H22.841H16.0351H12.4121H8.78904H2.57812C1.03955 1 0.661144 2.87867 1.28989 3.99863C1.37708 4.15393 1.50446 4.28124 1.63846 4.39857Z" fill="#F5F5F5" stroke="black" stroke-width="0.907066"/>
|
||||||
|
<path d="M15.0422 6.5331C14.7955 6.5331 14.5681 6.62806 14.3593 6.81946C14.1506 7.01087 13.9925 7.28302 13.8867 7.63592C13.7815 7.98882 13.7279 8.3021 13.7279 8.575C13.7279 8.93912 13.8017 9.20828 13.9485 9.38024C14.0962 9.55221 14.2788 9.63894 14.4965 9.63894L14.668 10.3791C14.5397 10.4113 14.407 10.427 14.2691 10.427C13.7935 10.427 13.3991 10.2625 13.0852 9.93352C12.9876 9.83109 12.9048 9.71744 12.8377 9.59333C12.6894 9.32043 12.6148 8.99669 12.6148 8.62136C12.6148 7.94621 12.8042 7.33012 13.1821 6.77385C13.6406 6.09571 14.2274 5.75702 14.9438 5.75702C14.999 5.75702 15.0527 5.75926 15.1041 5.7645L15.0422 6.53236V6.5331Z" fill="#70B62B"/>
|
||||||
|
<path d="M17.5203 5.50581C17.0104 5.03029 16.2984 4.79253 15.3852 4.79253C14.6091 4.79253 13.9515 4.95179 13.4118 5.2688C12.872 5.58581 12.4649 6.03218 12.1913 6.60863C11.9177 7.18434 11.7813 7.78397 11.7813 8.40679C11.7813 8.89876 11.882 9.35558 12.0832 9.77652C12.1295 9.87372 12.1817 9.96868 12.2383 10.0621C12.5432 10.5593 12.9719 10.9212 13.5228 11.1493C14.0738 11.3773 14.7045 11.4917 15.415 11.4917C16.1255 11.4917 16.6883 11.396 17.1804 11.2053C17.6732 11.0147 18.0698 10.7276 18.371 10.3463H19.2857C18.9994 10.9294 18.5603 11.3855 17.9669 11.7167C17.2877 12.0958 16.4602 12.2857 15.4836 12.2857C14.5069 12.2857 13.7219 12.1257 13.0353 11.8057C12.3479 11.485 11.8372 11.0132 11.5017 10.3881C11.167 9.76232 11 9.08193 11 8.34697C11 7.53948 11.1901 6.78807 11.5711 6.09124C11.952 5.39516 12.4739 4.87179 13.1352 4.52262C13.7964 4.17496 14.5524 4 15.403 4C16.1247 4 16.7659 4.14131 17.328 4.42244C17.8894 4.70356 18.318 5.10431 18.614 5.62245C18.9107 6.14059 19.0591 6.70732 19.0591 7.32191C19.0591 8.05388 18.8339 8.71632 18.3844 9.30773C17.82 10.0547 17.0969 10.4277 16.2149 10.4277C15.9778 10.4277 15.7982 10.3866 15.6774 10.3029C15.5574 10.2199 15.4769 10.098 15.4373 9.93727C15.2047 10.1623 14.9475 10.3096 14.6672 10.3799L14.4957 9.6397C14.659 9.6397 14.8118 9.59932 14.955 9.51858C15.0638 9.46175 15.1712 9.37128 15.2771 9.24717C15.4291 9.07222 15.5596 8.81651 15.6707 8.48006C15.781 8.14435 15.8362 7.83183 15.8362 7.54098C15.8362 7.21724 15.7609 6.96751 15.611 6.79405C15.4604 6.61985 15.2711 6.53312 15.0422 6.53312L15.1041 5.76526C15.5328 5.80264 15.8563 5.99554 16.0733 6.34396L16.1806 5.86544H17.3146L16.666 8.95408C16.6264 9.14997 16.6056 9.27633 16.6056 9.33315C16.6056 9.40493 16.622 9.45876 16.6555 9.49465C16.6891 9.53054 16.7278 9.54848 16.7733 9.54848C16.9105 9.54848 17.0887 9.46549 17.3056 9.29876C17.5979 9.08044 17.8342 8.7881 18.0146 8.42024C18.195 8.05239 18.2852 7.67182 18.2852 7.27855C18.2852 6.61611 18.0608 6.0561 17.6135 5.59628C17.5837 5.56563 17.5524 5.53572 17.5203 5.50581Z" fill="#70B62B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26" fill="none">
|
||||||
|
<g clip-path="url(#clip0_2948_1176)">
|
||||||
|
<path d="M0 23.2893L0 2.71C0 1.21331 1.21331 0 2.71 0L23.29 0C24.7867 0 26 1.21331 26 2.71V23.2893C26 24.7864 24.7864 26 23.2893 26H2.71067C1.21361 26 0 24.7864 0 23.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
|
||||||
|
<path d="M20.3343 9.82838L22.2668 11.6587M15 17L17.4497 16.8716L22.5941 11.4795C22.7228 11.3447 22.8249 11.1845 22.8945 11.0083C22.9642 10.8321 23 10.6432 23 10.4525C23 10.2617 22.9642 10.0729 22.8945 9.89664C22.8249 9.72042 22.7228 9.5603 22.5941 9.42542C22.4654 9.29055 22.3127 9.18356 22.1445 9.11056C21.9764 9.03757 21.7962 9 21.6142 9C21.4323 9 21.2521 9.03757 21.0839 9.11056C20.9158 9.18356 20.763 9.29055 20.6343 9.42542L15.4899 14.8175L15 17Z" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 12H15" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
|
||||||
|
<path d="M3 16H13" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
|
||||||
|
<path d="M3 20H15" stroke="#70B62B" stroke-width="0.892083" stroke-linecap="square"/>
|
||||||
|
<path d="M0 6L0 2.71067C0 1.21361 1.21361 0 2.71067 0L23.2893 0C24.7864 0 26 1.21361 26 2.71067V6H0Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_2948_1176">
|
||||||
|
<rect width="26" height="26" rx="2" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.4414 21.2559C14.3847 22.1215 14.1349 22.8742 13.7871 23.4141C13.3852 24.0377 12.9135 24.2998 12.499 24.2998C12.0847 24.2996 11.6136 24.0375 11.2119 23.4141C10.8642 22.8742 10.6133 22.1215 10.5566 21.2559C11.1641 21.2729 11.8114 21.2861 12.5 21.2861C13.1882 21.2861 13.8344 21.2729 14.4414 21.2559Z" fill="#F5F5F5" stroke="#808080" stroke-width="1.4"/>
|
||||||
|
<path d="M12.4297 0.700195C13.0843 0.700204 13.6259 1.23742 13.626 1.91406C13.626 2.59074 13.0844 3.12792 12.4297 3.12793C11.775 3.12793 11.2334 2.59075 11.2334 1.91406C11.2334 1.23741 11.775 0.700195 12.4297 0.700195Z" fill="white" stroke="#808080" stroke-width="1.4"/>
|
||||||
|
<path d="M12.4346 3.62988C13.0533 3.63005 13.3559 3.62244 13.873 3.70996C14.1827 3.76237 14.4159 3.83987 14.6787 3.94629L14.9551 4.0625C17.2877 5.0584 18.9277 7.39184 18.9277 10.1143V14.6738C18.9277 15.7754 19.438 16.8147 20.3086 17.4844C20.5835 17.6958 20.9084 17.9327 21.2842 18.1865C21.3461 18.2283 21.3876 18.3013 21.3877 18.3838V20.2969C21.3876 20.3982 21.3261 20.4829 21.2432 20.5166C20.4748 20.8286 18.2879 21.3262 12.4277 21.3262C6.93322 21.3262 4.66803 20.8889 3.77344 20.5771L3.6123 20.5166C3.52941 20.4829 3.46882 20.3982 3.46875 20.2969V18.3838C3.46882 18.322 3.4918 18.2657 3.5293 18.2236L3.57129 18.1865C3.94707 17.9327 4.27194 17.6958 4.54688 17.4844C5.41738 16.8149 5.9276 15.7764 5.92773 14.6748V10.1562C5.92773 6.92932 7.89268 4.10108 10.9541 3.71484C11.539 3.64107 11.8416 3.62973 12.4346 3.62988Z" fill="#F5F5F5" stroke="#808080" stroke-width="1.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M4.06082 3.75646C4.2774 3.8327 4.50791 3.86117 4.73652 3.83993C4.96514 3.81868 5.18646 3.74823 5.38528 3.63339C5.58411 3.51856 5.75573 3.36207 5.88838 3.17465C6.02102 2.98724 6.11155 2.77335 6.15374 2.54766L6.4084 1.1789C7.4555 0.940367 8.54285 0.940367 9.58996 1.1789L9.8462 2.54766C9.8884 2.77335 9.97892 2.98724 10.1116 3.17465C10.2442 3.36207 10.4158 3.51856 10.6147 3.63339C10.8135 3.74823 11.0348 3.81868 11.2634 3.83993C11.492 3.86117 11.7226 3.8327 11.9391 3.75646L13.2514 3.2949C13.9818 4.08176 14.5261 5.02277 14.8438 6.04833L13.7854 6.95553C13.611 7.10495 13.4711 7.29031 13.3752 7.49889C13.2793 7.70748 13.2296 7.93435 13.2296 8.16394C13.2296 8.39353 13.2793 8.6204 13.3752 8.82898C13.4711 9.03757 13.611 9.22293 13.7854 9.37234L14.8438 10.2787C14.5261 11.3043 13.9818 12.2453 13.2514 13.0322L11.9383 12.5706C11.7218 12.4944 11.4912 12.4659 11.2626 12.4872C11.034 12.5084 10.8127 12.5789 10.6139 12.6937C10.415 12.8085 10.2434 12.965 10.1108 13.1524C9.97813 13.3398 9.8876 13.5537 9.84541 13.7794L9.59155 15.1482C8.54444 15.3867 7.4571 15.3867 6.40999 15.1482L6.15374 13.7794C6.11155 13.5537 6.02102 13.3398 5.88838 13.1524C5.75573 12.965 5.58411 12.8085 5.38528 12.6937C5.18646 12.5789 4.96514 12.5084 4.73652 12.4872C4.50791 12.4659 4.2774 12.4944 4.06082 12.5706L2.74856 13.0322C2.0181 12.2453 1.47389 11.3043 1.15619 10.2787L2.21459 9.37155C2.38877 9.22213 2.52858 9.03682 2.62443 8.82831C2.72028 8.6198 2.7699 8.39303 2.7699 8.16354C2.7699 7.93405 2.72028 7.70728 2.62443 7.49877C2.52858 7.29026 2.38877 7.10495 2.21459 6.95553L1.15619 6.04833C1.47366 5.02284 2.01759 4.08184 2.74777 3.2949L4.06082 3.75646ZM7.99997 5.77617C8.63314 5.77617 9.24038 6.0277 9.6881 6.47542C10.1358 6.92313 10.3873 7.53037 10.3873 8.16354C10.3873 8.79671 10.1358 9.40395 9.6881 9.85166C9.24038 10.2994 8.63314 10.5509 7.99997 10.5509C7.36681 10.5509 6.75957 10.2994 6.31185 9.85166C5.86413 9.40395 5.61261 8.79671 5.61261 8.16354C5.61261 7.53037 5.86413 6.92313 6.31185 6.47542C6.75957 6.0277 7.36681 5.77617 7.99997 5.77617Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_4199_10908)">
|
||||||
|
<g filter="url(#filter0_di_4199_10908)">
|
||||||
|
<circle cx="17.5" cy="17.5" r="17.5" fill="#FDFDFD"/>
|
||||||
|
<circle cx="17.5" cy="17.5" r="17" stroke="url(#paint0_linear_4199_10908)" stroke-opacity="0.2"/>
|
||||||
|
</g>
|
||||||
|
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
|
||||||
|
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
|
||||||
|
<path d="M15.7661 32.1768L16.9463 25.8849C16.9619 25.8022 17.0341 25.7422 17.1183 25.7422H17.7667C17.8518 25.7422 17.9246 25.8034 17.9391 25.8873L19.0289 32.1811C19.0379 32.2325 19.0233 32.2853 18.9893 32.3249L17.5726 33.975C17.504 34.0548 17.3811 34.0565 17.3103 33.9787L15.8086 32.3268C15.7717 32.2862 15.7559 32.2307 15.7661 32.1768Z" fill="#70B62B"/>
|
||||||
|
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
|
||||||
|
<rect x="22.451" y="12.5248" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5248)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_4199_10908" x="-2" y="-1" width="39" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="1"/>
|
||||||
|
<feGaussianBlur stdDeviation="1"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4199_10908"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4199_10908" result="shape"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="-0.25"/>
|
||||||
|
<feGaussianBlur stdDeviation="1.5"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_4199_10908"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_4199_10908" x1="1.09375" y1="1.59091" x2="35" y2="1.59091" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_4199_10908">
|
||||||
|
<rect width="35" height="35" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26" fill="none">
|
||||||
|
<g clip-path="url(#clip0_9701_40694)">
|
||||||
|
<rect width="26" height="26" rx="2" fill="white"></rect>
|
||||||
|
<path d="M0 23.2893L0 2.71C0 1.21331 1.21331 0 2.71 0L23.29 0C24.7867 0 26 1.21331 26 2.71V23.2893C26 24.7864 24.7864 26 23.2893 26H2.71067C1.21361 26 0 24.7864 0 23.2893Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||||
|
<path d="M0 7.5L0 2.71067C0 1.21361 1.21361 0 2.71067 0L23.2893 0C24.7864 0 26 1.21361 26 2.71067V7.5H0Z" fill="#F5F5F5" stroke="black" stroke-width="0.903555"></path>
|
||||||
|
<circle cx="2.75" cy="3.75" r="0.75" fill="#70B62B"></circle>
|
||||||
|
<circle cx="4.95001" cy="3.75" r="0.75" fill="#70B62B"></circle>
|
||||||
|
<circle cx="7.15002" cy="3.75" r="0.75" fill="#70B62B"></circle>
|
||||||
|
<path d="M10 4H24" stroke="#70B62B" stroke-width="0.9" stroke-linecap="square"></path>
|
||||||
|
<rect x="2.45" y="10.45" width="9.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
|
||||||
|
<rect x="2.45" y="15.45" width="14.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
|
||||||
|
<rect x="7.45" y="20.45" width="16.1" height="3.1" rx="0.55" stroke="black" stroke-width="0.9"></rect>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_9701_40694">
|
||||||
|
<rect width="26" height="26" rx="2" fill="white"></rect>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 7.5H19.5341" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 12.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 17.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21.0755 4.16406L3.39658 4.16406C2.71318 4.16406 2.15918 4.71777 2.15918 5.4008L2.15918 19.0967C2.15918 19.7797 2.71318 20.3335 3.39658 20.3335H21.0755C21.7589 20.3335 22.3129 19.7797 22.3129 19.0967V5.4008C22.3129 4.71777 21.7589 4.16406 21.0755 4.16406Z" stroke="#808080" stroke-width="1.15" stroke-miterlimit="10"/>
|
||||||
|
<path d="M9.10286 12.375L2.60676 18.15C1.74915 18.9153 2.28822 20.3357 3.43986 20.3357H21.033C22.1846 20.3357 22.7237 18.9153 21.8661 18.15L15.37 12.375" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.6101 14.5783L21.8661 6.34978C22.7237 5.58447 22.1846 4.16406 21.033 4.16406L3.43986 4.16406C2.28822 4.16406 1.74915 5.58447 2.60676 6.34978L11.8628 14.5783C12.0772 14.7681 12.4018 14.7681 12.6101 14.5783Z" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_mobile_spreed)">
|
||||||
|
<path d="M11.1875 19.743V18.7995C11.1875 18.3126 11.5822 17.918 12.069 17.918H13.3336C13.8204 17.918 14.2151 18.3126 14.2151 18.7995V19.743C14.2151 20.2298 13.8204 20.6245 13.3336 20.6245H12.069C11.5822 20.6245 11.1875 20.2298 11.1875 19.743Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
<path d="M7.09047 15.9141C8.56573 17.3894 10.4773 18.1623 12.4098 18.2329C14.5365 18.3106 16.6884 17.5376 18.3119 15.9141C21.4106 12.8154 21.4106 7.7915 18.3119 4.69274C15.2131 1.59398 10.1892 1.59398 7.09047 4.69274C3.99171 7.7915 3.99171 12.8154 7.09047 15.9141Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.1764 12.8321C10.8403 13.496 11.7005 13.8438 12.5701 13.8755C13.527 13.9105 14.4954 13.5627 15.2259 12.8321C16.6203 11.4377 16.6203 9.17699 15.2259 7.78258C13.8315 6.38816 11.5708 6.38816 10.1764 7.78258C8.782 9.17699 8.782 11.4377 10.1764 12.8321Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.1779 5.30035C12.3155 5.43795 12.4938 5.51003 12.674 5.51662C12.8723 5.52386 13.073 5.45177 13.2245 5.30035C13.5135 5.01134 13.5135 4.54278 13.2245 4.25376C12.9355 3.96475 12.4669 3.96475 12.1779 4.25376C11.8889 4.54278 11.8889 5.01134 12.1779 5.30035Z" fill="#808080"/>
|
||||||
|
<path d="M7.47754 22.1609V21.2174C7.47754 20.7306 7.8722 20.3359 8.35905 20.3359H17.0433C17.5302 20.3359 17.9248 20.7306 17.9248 21.2174V22.1609C17.9248 22.6478 17.5302 23.0425 17.0433 23.0425H8.35905C7.87221 23.0425 7.47754 22.6478 7.47754 22.1609Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_mobile_spreed">
|
||||||
|
<rect width="22.0417" height="23" fill="white" transform="translate(1.47949 1)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 20.0191V4.98038C3 3.88665 3.88665 3 4.98038 3H20.0196C21.1133 3 22 3.88665 22 4.98038V20.0191C22 21.1131 21.1131 22 20.0191 22H4.98087C3.88687 22 3 21.1131 3 20.0191Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
<path d="M3 8.48077V4.98087C3 3.88687 3.88687 3 4.98087 3H20.0191C21.1131 3 22 3.88687 22 4.98087V8.48077H3Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
<mask id="path-3-inside-1_mobile_tasks" fill="white">
|
||||||
|
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769"/>
|
||||||
|
</mask>
|
||||||
|
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-3-inside-1_mobile_tasks)"/>
|
||||||
|
<mask id="path-4-inside-2_mobile_tasks" fill="white">
|
||||||
|
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769"/>
|
||||||
|
</mask>
|
||||||
|
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-4-inside-2_mobile_tasks)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.9847 20L7.43923 13.1999C7.01817 12.8204 7.01861 12.1598 7.44019 11.7809L14.9847 5" stroke="#808080" stroke-width="1.73077" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
@@ -9,22 +9,25 @@ 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.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.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
|
||||||
@@ -35,8 +38,10 @@ 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
|
||||||
@@ -47,15 +52,21 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
// Живой forbion (_header.css .app-menu--mobile): нижний отступ 90 под плавающую панель,
|
||||||
|
// иконки 62, сетка 3 колонки с gap 8, БЕЗ скругления верха и БЕЗ ручки-хендла,
|
||||||
|
// высота — по контенту (почти весь экран), фон #FBFBFB.
|
||||||
private val BottomBarReserve: Dp = 90.dp
|
private val BottomBarReserve: Dp = 90.dp
|
||||||
private val MenuIconSize = 62.dp
|
private val MenuIconSize = 62.dp
|
||||||
private val MenuGridGap = 20.dp
|
private val MenuGridGap = 8.dp
|
||||||
|
|
||||||
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
|
||||||
@@ -78,58 +89,81 @@ fun F7AppMenuSheet(
|
|||||||
}
|
}
|
||||||
val base = serverUrl.trimEnd('/')
|
val base = serverUrl.trimEnd('/')
|
||||||
|
|
||||||
AnimatedVisibility(
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
visible = visible,
|
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
|
||||||
enter = fadeIn(tween(250)) + slideInVertically(
|
AnimatedVisibility(
|
||||||
animationSpec = tween(350),
|
visible = visible,
|
||||||
initialOffsetY = { it },
|
enter = fadeIn(tween(200)),
|
||||||
),
|
exit = fadeOut(tween(200)),
|
||||||
exit = fadeOut(tween(200)) + slideOutVertically(
|
modifier = Modifier.fillMaxSize(),
|
||||||
animationSpec = tween(300),
|
|
||||||
targetOffsetY = { it },
|
|
||||||
),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = BottomBarReserve)
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.background(F7Colors.Background)
|
|
||||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
|
||||||
) {
|
) {
|
||||||
F7AppMenuSearchField(
|
Box(
|
||||||
serverUrl = base,
|
modifier = Modifier
|
||||||
value = searchQuery,
|
.fillMaxSize()
|
||||||
onValueChange = { searchQuery = it },
|
.background(Color.Black.copy(alpha = 0.18f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Сам лист — выезжает снизу вверх (translateY 100%→0), как в живом forbion:
|
||||||
|
// без скругления верха и без ручки, высота по контенту до почти всего экрана.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(250)) + slideInVertically(
|
||||||
|
animationSpec = tween(350),
|
||||||
|
initialOffsetY = { it },
|
||||||
|
),
|
||||||
|
exit = fadeOut(tween(200)) + slideOutVertically(
|
||||||
|
animationSpec = tween(300),
|
||||||
|
targetOffsetY = { it },
|
||||||
|
),
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 24.dp),
|
.heightIn(max = 640.dp)
|
||||||
)
|
.background(F7Colors.Background)
|
||||||
LazyVerticalGrid(
|
.navigationBarsPadding()
|
||||||
columns = GridCells.Fixed(4),
|
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = BottomBarReserve)
|
||||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
|
||||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
F7AppMenuSearchField(
|
||||||
items = filteredItems,
|
serverUrl = base,
|
||||||
key = { index, item -> "${item.label}-$index" },
|
value = searchQuery,
|
||||||
) { index, item ->
|
onValueChange = { searchQuery = it },
|
||||||
val originalIndex = items.indexOf(item)
|
modifier = Modifier
|
||||||
Box(
|
.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.padding(bottom = 24.dp),
|
||||||
contentAlignment = Alignment.TopCenter,
|
)
|
||||||
) {
|
// Сетка 3 колонки, gap 8 (живой forbion). Не-ленивая — лист сам в verticalScroll.
|
||||||
F7AppMenuGridItem(
|
Column(
|
||||||
item = item,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = {
|
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||||
if (originalIndex >= 0) {
|
) {
|
||||||
onItemClick(originalIndex)
|
filteredItems.chunked(3).forEach { rowItems ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||||
|
) {
|
||||||
|
rowItems.forEach { item ->
|
||||||
|
val originalIndex = items.indexOf(item)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
F7AppMenuGridItem(
|
||||||
|
item = item,
|
||||||
|
onClick = { if (originalIndex >= 0) onItemClick(originalIndex) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
// добить ряд пустыми ячейками для выравнивания по левому краю
|
||||||
|
repeat(3 - rowItems.size) { Box(modifier = Modifier.weight(1f)) {} }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,17 +229,39 @@ private fun F7AppMenuGridItem(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(MenuIconSize)
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick),
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
val localIcon = item.localIcon
|
||||||
model = item.iconUrl,
|
if (localIcon != null) {
|
||||||
contentDescription = item.label,
|
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
|
||||||
modifier = Modifier.size(MenuIconSize),
|
Box(
|
||||||
contentScale = ContentScale.Fit,
|
modifier = Modifier
|
||||||
)
|
.size(MenuIconSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White)
|
||||||
|
.border(1.5.dp, F7Colors.Green30, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
localIcon,
|
||||||
|
contentDescription = item.label,
|
||||||
|
tint = F7Colors.Primary,
|
||||||
|
modifier = Modifier.size(MenuIconSize * 0.46f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.iconUrl,
|
||||||
|
contentDescription = item.label,
|
||||||
|
modifier = Modifier.size(MenuIconSize),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.labelLarge.copy(
|
style = MaterialTheme.typography.labelLarge.copy(
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ package ru.forbion.f7cloud.core.designsystem
|
|||||||
enum class F7BottomBarSlot {
|
enum class F7BottomBarSlot {
|
||||||
Chats,
|
Chats,
|
||||||
NavBack,
|
NavBack,
|
||||||
Create,
|
SectionMail,
|
||||||
Profile,
|
SectionTasks,
|
||||||
Notifications,
|
SectionConferences,
|
||||||
Settings,
|
|
||||||
Menu,
|
Menu,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,72 +15,20 @@ data class F7BottomBarConfig(
|
|||||||
val buttonCount: Int get() = slots.size
|
val buttonCount: Int get() = slots.size
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
// Нижняя панель 1:1 с мобильным сайтом forbion (layout.user.php):
|
||||||
|
// «назад», затем ярлыки-разделы Почта · Задачи · Конференции, справа — меню.
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun forContext(
|
fun forContext(
|
||||||
tabKey: String,
|
tabKey: String,
|
||||||
talkInRoom: Boolean,
|
talkInRoom: Boolean,
|
||||||
): F7BottomBarConfig = when (tabKey) {
|
): F7BottomBarConfig = F7BottomBarConfig(
|
||||||
"Talk" -> if (talkInRoom) {
|
listOf(
|
||||||
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
|
F7BottomBarSlot.NavBack,
|
||||||
} else {
|
F7BottomBarSlot.SectionMail,
|
||||||
F7BottomBarConfig(
|
F7BottomBarSlot.SectionTasks,
|
||||||
listOf(
|
F7BottomBarSlot.SectionConferences,
|
||||||
F7BottomBarSlot.Chats,
|
F7BottomBarSlot.Menu,
|
||||||
F7BottomBarSlot.Profile,
|
),
|
||||||
F7BottomBarSlot.Notifications,
|
)
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"Files" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.NavBack,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Create,
|
|
||||||
F7BottomBarSlot.Settings,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Contacts" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Create,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Tasks" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Create,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Support" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Create,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Mail", "Calendar" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.NavBack,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Settings,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,25 @@ package ru.forbion.f7cloud.core.designsystem
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Palette from themes/forbion (mobile + f7support), light theme.
|
* Palette from themes/forbion. Base-токены — из Figma «Брендбук» (палитра владельца, 2026-07-08),
|
||||||
|
* семантические — поверх Base. Светлая тема.
|
||||||
*/
|
*/
|
||||||
object F7Colors {
|
object F7Colors {
|
||||||
val Primary = Color(0xFF70B62B)
|
// --- Base palette (Figma «Брендбук») — точные значения ---
|
||||||
|
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)
|
||||||
@@ -15,19 +30,22 @@ object F7Colors {
|
|||||||
|
|
||||||
val Background = Color(0xFFFBFBFB)
|
val Background = Color(0xFFFBFBFB)
|
||||||
val Surface = Color(0xFFFFFFFF)
|
val Surface = Color(0xFFFFFFFF)
|
||||||
val SurfaceMuted = Color(0xFFF5F5F5)
|
val SurfaceMuted = Grey2
|
||||||
|
|
||||||
val TextPrimary = Color(0xFF151515)
|
val TextPrimary = Black
|
||||||
val TextSecondary = Color(0xFF808080)
|
val TextSecondary = Grey1
|
||||||
val TextMuted = Color(0xFF8C8C8C)
|
val TextMuted = Color(0xFF8C8C8C)
|
||||||
val TextOnPrimary = Color(0xFFFFFFFF)
|
val TextOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
|
||||||
val Border = Color(0xFFE6E6E6)
|
val Border = Grey3
|
||||||
val BorderLight = Color(0xFFE0E0E0)
|
val BorderLight = Color(0xFFE0E0E0)
|
||||||
val SecondaryButtonBg = Color(0xFFFDFDFD)
|
val SecondaryButtonBg = Color(0xFFFDFDFD)
|
||||||
val SecondaryButtonBorder = Color(0xFFE6E6E6)
|
val SecondaryButtonBorder = Grey3
|
||||||
|
|
||||||
|
// Семантика ошибок — по боевой теме forbion (_base.css: --color-element-error /
|
||||||
|
// --color-text-error / --color-error); базовый Red #FF7A66 из брендбука остаётся для тегов.
|
||||||
val Error = Color(0xFFD74642)
|
val Error = Color(0xFFD74642)
|
||||||
|
val ErrorText = Color(0xFFD42722)
|
||||||
val ErrorBg = Color(0xFFFFE2E2)
|
val ErrorBg = Color(0xFFFFE2E2)
|
||||||
|
|
||||||
val StatusNew = Color(0xFF2B9AB6)
|
val StatusNew = Color(0xFF2B9AB6)
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -43,11 +46,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +75,46 @@ fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый экран ошибки (по центру): иконка + сообщение + кнопка «Повторить».
|
||||||
|
* Используется везде, где загрузка упала и данных нет — вместо россыпи красного текста.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun F7ErrorState(
|
||||||
|
message: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRetry: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(F7Colors.Error.copy(alpha = 0.12f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("!", color = F7Colors.Error, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (onRetry != null) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
F7SecondaryButton(text = "Повторить", onClick = onRetry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun F7ModuleScreen(
|
fun F7ModuleScreen(
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
@@ -76,6 +122,9 @@ fun F7ModuleScreen(
|
|||||||
loading: Boolean = false,
|
loading: Boolean = false,
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
onRefresh: (() -> Unit)? = null,
|
onRefresh: (() -> Unit)? = null,
|
||||||
|
// Действие «Повторить» в экране ошибки; по умолчанию — как onRefresh.
|
||||||
|
// Позволяет дать retry, не показывая хедер-кнопку ↻ (когда onRefresh не задан).
|
||||||
|
onErrorRetry: (() -> Unit)? = onRefresh,
|
||||||
headerActions: @Composable RowScope.() -> Unit = {},
|
headerActions: @Composable RowScope.() -> Unit = {},
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -126,14 +175,22 @@ fun F7ModuleScreen(
|
|||||||
CircularProgressIndicator(color = F7Colors.Primary)
|
CircularProgressIndicator(color = F7Colors.Primary)
|
||||||
}
|
}
|
||||||
if (!error.isNullOrBlank()) {
|
if (!error.isNullOrBlank()) {
|
||||||
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
// Ошибка без данных → центрированный экран с «Повторить»
|
||||||
|
F7ErrorState(
|
||||||
|
message = error,
|
||||||
|
onRetry = onErrorRetry,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,7 +627,10 @@ fun F7AppScaffold(
|
|||||||
content(
|
content(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.f7SafeTopInsets(),
|
// Отступы под статус-бар (сверху) И системную навигацию (снизу),
|
||||||
|
// иначе на edge-to-edge (targetSdk 36) контент залезает под панели.
|
||||||
|
.f7SafeTopInsets()
|
||||||
|
.f7SafeBottomInsets(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Зелёная круглая кнопка «+» — единый элемент создания в шапках экранов
|
||||||
|
* (Почта/Файлы/Контакты/…). Градиент Primary, как у других зелёных кнопок.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun F7CreateButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 44.dp,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.shadow(3.dp, CircleShape, spotColor = F7Colors.Primary.copy(alpha = 0.25f))
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), CircleShape)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "+",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -21,29 +22,37 @@ 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 onCreateClick: () -> Unit = {},
|
val onMailClick: () -> Unit = {},
|
||||||
val onProfileClick: () -> Unit = {},
|
val onTasksClick: () -> Unit = {},
|
||||||
val onNotificationsClick: () -> Unit = {},
|
val onConferencesClick: () -> Unit = {},
|
||||||
val onSettingsClick: () -> Unit = {},
|
|
||||||
val onMenuClick: () -> Unit = {},
|
val onMenuClick: () -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Иконки нижней панели — ЛОКАЛЬНЫЕ mobile-*-icon из живой темы (assets/menu/), 1:1 с сайтом.
|
||||||
|
private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
|
||||||
|
|
||||||
|
// Живой forbion (_header.css .header__mobile-bottom): пилюля 328×61, паддинг 3,
|
||||||
|
// фон #F5F5F5; кнопки 55, фон #FBFBFB, рамка #E6E6E6; активная кнопка-раздел и открытый
|
||||||
|
// бургер — полупрозрачный зелёный градиент 104°.
|
||||||
|
private val BottomBarWidth = 328.dp
|
||||||
|
private val BottomBarHeight = 61.dp
|
||||||
private val BottomBarButtonSize = 55.dp
|
private val BottomBarButtonSize = 55.dp
|
||||||
private val BottomBarIconSize = 24.dp
|
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
|
||||||
private val BottomBarGap = 8.dp
|
private val BottomBarIconSize = 25.dp
|
||||||
private val BottomBarOuterPaddingH = 6.dp
|
private val BottomBarSectionIconSize = 25.dp
|
||||||
private val BottomBarOuterPaddingV = 6.dp
|
private val BottomBarBackIconSize = 18.dp
|
||||||
|
private val BottomBarOuterPadding = 3.dp
|
||||||
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
||||||
private val BottomBarBorderBrush = Brush.linearGradient(
|
private val BottomBarButtonBg = Color(0xFFFBFBFB)
|
||||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
private val BottomBarButtonBorder = Color(0xFFE6E6E6)
|
||||||
)
|
private val BottomBarActiveBrush = Brush.linearGradient(
|
||||||
private val BottomBarHighlightBrush = Brush.linearGradient(
|
|
||||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +62,7 @@ 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,
|
||||||
@@ -60,10 +70,11 @@ fun F7MobileBottomBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val base = serverUrl.trimEnd('/')
|
val base = serverUrl.trimEnd('/')
|
||||||
val pillShape = RoundedCornerShape(percent = 50)
|
val pillShape = RoundedCornerShape(100.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.wrapContentWidth()
|
.width(BottomBarWidth)
|
||||||
|
.height(BottomBarHeight)
|
||||||
.shadow(
|
.shadow(
|
||||||
elevation = 2.dp,
|
elevation = 2.dp,
|
||||||
shape = pillShape,
|
shape = pillShape,
|
||||||
@@ -71,11 +82,8 @@ fun F7MobileBottomBar(
|
|||||||
)
|
)
|
||||||
.clip(pillShape)
|
.clip(pillShape)
|
||||||
.background(Color(0xFFF5F5F5))
|
.background(Color(0xFFF5F5F5))
|
||||||
.padding(
|
.padding(BottomBarOuterPadding),
|
||||||
horizontal = BottomBarOuterPaddingH,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
vertical = BottomBarOuterPaddingV,
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
config.slots.forEach { slot ->
|
config.slots.forEach { slot ->
|
||||||
@@ -87,45 +95,42 @@ fun F7MobileBottomBar(
|
|||||||
"$base/themes/forbion/images/header/chat-icon-gray.svg"
|
"$base/themes/forbion/images/header/chat-icon-gray.svg"
|
||||||
},
|
},
|
||||||
contentDescription = "Чаты",
|
contentDescription = "Чаты",
|
||||||
highlighted = chatsHighlighted,
|
active = chatsHighlighted,
|
||||||
onClick = actions.onChatsClick,
|
onClick = actions.onChatsClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
|
iconUrl = barIcon("sidebar-chevron-left.svg"),
|
||||||
contentDescription = "Папки",
|
contentDescription = "Назад",
|
||||||
highlighted = navBackHighlighted,
|
iconSize = BottomBarBackIconSize,
|
||||||
iconRotation = if (navBackHighlighted) 180f else 0f,
|
iconRotation = if (navBackHighlighted) 180f else 0f,
|
||||||
onClick = actions.onNavBackClick,
|
onClick = actions.onNavBackClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
|
iconUrl = barIcon("mobile-mail-icon.svg"),
|
||||||
contentDescription = "Создать",
|
contentDescription = "Почта",
|
||||||
onClick = actions.onCreateClick,
|
iconSize = BottomBarSectionIconSize,
|
||||||
|
active = activeTabKey.equals("mail", ignoreCase = true),
|
||||||
|
onClick = actions.onMailClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionTasks -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
|
iconUrl = barIcon("mobile-tasks-icon.svg"),
|
||||||
contentDescription = "Профиль",
|
contentDescription = "Задачи",
|
||||||
onClick = actions.onProfileClick,
|
iconSize = BottomBarSectionIconSize,
|
||||||
|
active = activeTabKey.equals("tasks", ignoreCase = true),
|
||||||
|
onClick = actions.onTasksClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
|
iconUrl = barIcon("mobile-spreed-icon.svg"),
|
||||||
contentDescription = "Уведомления",
|
contentDescription = "Конференции",
|
||||||
showBadge = showNotificationBadge,
|
iconSize = BottomBarSectionIconSize,
|
||||||
onClick = actions.onNotificationsClick,
|
active = activeTabKey.equals("talk", ignoreCase = true),
|
||||||
)
|
onClick = actions.onConferencesClick,
|
||||||
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 = barIcon("mobile-burger-icon.svg"),
|
||||||
"$base/themes/forbion/images/header/menu-burger-green.svg"
|
|
||||||
} else {
|
|
||||||
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
|
||||||
},
|
|
||||||
contentDescription = "Меню",
|
contentDescription = "Меню",
|
||||||
highlighted = menuOpen,
|
active = menuOpen,
|
||||||
|
showBadge = showNotificationBadge,
|
||||||
onClick = actions.onMenuClick,
|
onClick = actions.onMenuClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -138,25 +143,27 @@ private fun F7BottomBarIconSlot(
|
|||||||
iconUrl: String,
|
iconUrl: String,
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
highlighted: Boolean = false,
|
active: Boolean = false,
|
||||||
iconRotation: Float = 0f,
|
iconRotation: Float = 0f,
|
||||||
showBadge: Boolean = false,
|
showBadge: Boolean = false,
|
||||||
|
iconSize: Dp = BottomBarIconSize,
|
||||||
) {
|
) {
|
||||||
val bg = if (highlighted) BottomBarHighlightBrush else null
|
// Живой forbion: кнопка 55, фон #FBFBFB + рамка #E6E6E6; активная (текущий раздел /
|
||||||
|
// открытый бургер) — полупрозрачный зелёный градиент 104°.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(BottomBarButtonSize)
|
.size(BottomBarButtonSize)
|
||||||
.clip(BottomBarButtonShape)
|
.clip(BottomBarButtonShape)
|
||||||
.then(
|
.then(
|
||||||
if (bg != null) {
|
if (active) {
|
||||||
Modifier.background(bg, BottomBarButtonShape)
|
Modifier.background(BottomBarActiveBrush, BottomBarButtonShape)
|
||||||
} else {
|
} else {
|
||||||
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
|
Modifier.background(BottomBarButtonBg, BottomBarButtonShape)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.border(
|
.border(
|
||||||
width = 1.dp,
|
width = 1.dp,
|
||||||
brush = BottomBarBorderBrush,
|
color = BottomBarButtonBorder,
|
||||||
shape = BottomBarButtonShape,
|
shape = BottomBarButtonShape,
|
||||||
)
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
@@ -170,7 +177,7 @@ private fun F7BottomBarIconSlot(
|
|||||||
model = iconUrl,
|
model = iconUrl,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(BottomBarIconSize)
|
.size(iconSize)
|
||||||
.graphicsLayer { rotationZ = iconRotation },
|
.graphicsLayer { rotationZ = iconRotation },
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает
|
||||||
|
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
|
||||||
|
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun F7SecureScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val window = (context as? Activity)?.window
|
||||||
|
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
onDispose {
|
||||||
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -23,7 +23,10 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation libs.coroutines.android
|
||||||
implementation 'org.json:json:20240303'
|
implementation libs.json
|
||||||
api 'com.squareup.okhttp3:okhttp:4.12.0'
|
api libs.okhttp
|
||||||
|
|
||||||
|
testImplementation libs.junit
|
||||||
|
testImplementation libs.okhttp.mockwebserver
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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<DavEntry></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 <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
||||||
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List<DavEvent></ID>
|
||||||
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List<DavTask></ID>
|
||||||
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar></ID>
|
||||||
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List<DavTrashEvent></ID>
|
||||||
|
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>></ID>
|
||||||
|
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List<DavContact></ID>
|
||||||
|
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></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 <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></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>
|
||||||
@@ -22,6 +22,9 @@ data class DavCalendar(
|
|||||||
val href: String,
|
val href: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
|
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||||
|
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||||
|
val ctag: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DavEvent(
|
data class DavEvent(
|
||||||
@@ -103,7 +106,7 @@ object CalDavClient {
|
|||||||
val body = """
|
val body = """
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
||||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/></d:prop>
|
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
|
||||||
</d:propfind>
|
</d:propfind>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||||
@@ -129,7 +132,15 @@ object CalDavClient {
|
|||||||
calendar: DavCalendar,
|
calendar: DavCalendar,
|
||||||
rangeStart: Instant,
|
rangeStart: Instant,
|
||||||
rangeEnd: Instant,
|
rangeEnd: Instant,
|
||||||
): List<DavEvent> {
|
): List<DavEvent> = parseEventsXml(queryEventsRawXml(client, calendar, rangeStart, rangeEnd), calendar)
|
||||||
|
|
||||||
|
/** Сырой REPORT-ответ (для кэширования на диске с ключом по CTag). */
|
||||||
|
fun queryEventsRawXml(
|
||||||
|
client: OkHttpClient,
|
||||||
|
calendar: DavCalendar,
|
||||||
|
rangeStart: Instant,
|
||||||
|
rangeEnd: Instant,
|
||||||
|
): String {
|
||||||
val startStr = formatCalDavTime(rangeStart)
|
val startStr = formatCalDavTime(rangeStart)
|
||||||
val endStr = formatCalDavTime(rangeEnd)
|
val endStr = formatCalDavTime(rangeEnd)
|
||||||
val body = """
|
val body = """
|
||||||
@@ -146,10 +157,13 @@ object CalDavClient {
|
|||||||
</c:calendar-query>
|
</c:calendar-query>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val href = calendar.href.trimEnd('/') + "/"
|
val href = calendar.href.trimEnd('/') + "/"
|
||||||
val xml = report(client, href, body)
|
return report(client, href, body)
|
||||||
return parseCalendarQueryResponses(xml, calendar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
|
||||||
|
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
|
||||||
|
parseCalendarQueryResponses(xml, calendar)
|
||||||
|
|
||||||
fun createEvent(
|
fun createEvent(
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
calendar: DavCalendar,
|
calendar: DavCalendar,
|
||||||
@@ -189,7 +203,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -244,7 +257,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -278,7 +290,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -314,7 +325,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -347,7 +357,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -405,7 +414,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -581,25 +589,9 @@ object CalDavClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseIcsInstant(raw: String): Instant? {
|
// Единый парсер дат — в CalendarIcs (учитывает Z/TZID/floating). Раньше здесь была
|
||||||
val value = raw.trim()
|
// расходящаяся копия (трактовала всё как UTC) — источник рассинхрона календаря и задач.
|
||||||
if (value.isBlank()) return null
|
fun parseIcsInstant(raw: String): Instant? = CalendarIcs.parseIcsInstant(raw)
|
||||||
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)
|
||||||
|
|
||||||
@@ -660,7 +652,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -707,7 +698,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -721,7 +711,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -781,7 +770,6 @@ 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")
|
||||||
@@ -808,6 +796,7 @@ object CalDavClient {
|
|||||||
var href = ""
|
var href = ""
|
||||||
var displayName = ""
|
var displayName = ""
|
||||||
var calendarColor: String? = null
|
var calendarColor: String? = null
|
||||||
|
var ctag = ""
|
||||||
var isCollection = false
|
var isCollection = false
|
||||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
when (parser.eventType) {
|
when (parser.eventType) {
|
||||||
@@ -817,11 +806,13 @@ object CalDavClient {
|
|||||||
href = ""
|
href = ""
|
||||||
displayName = ""
|
displayName = ""
|
||||||
calendarColor = null
|
calendarColor = null
|
||||||
|
ctag = ""
|
||||||
isCollection = false
|
isCollection = false
|
||||||
}
|
}
|
||||||
"collection" -> if (inResponse) isCollection = true
|
"collection" -> if (inResponse) isCollection = true
|
||||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||||
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
||||||
|
"getctag" -> if (inResponse) ctag = parser.readText().trim()
|
||||||
"href" -> if (inResponse) href = parser.readText().trim()
|
"href" -> if (inResponse) href = parser.readText().trim()
|
||||||
}
|
}
|
||||||
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
||||||
@@ -835,7 +826,7 @@ object CalDavClient {
|
|||||||
val name = displayName.ifBlank {
|
val name = displayName.ifBlank {
|
||||||
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
||||||
}
|
}
|
||||||
out += DavCalendar(href = full, displayName = name, color = calendarColor)
|
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inResponse = false
|
inResponse = false
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ 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)
|
||||||
@@ -70,23 +73,22 @@ object CalendarIcs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseVEventBlock(block: String): CalendarEventData? {
|
private fun parseVEventBlock(block: String): CalendarEventData? {
|
||||||
val lines = parseLines(block)
|
val props = parseProps(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 dtStartRaw = lines["DTSTART"].orEmpty()
|
val dtStart = props.firstOrNull { it.name == "DTSTART" } ?: return null
|
||||||
val start = parseIcsInstant(dtStartRaw) ?: return null
|
val start = parseIcsInstant(dtStart.value, dtStart.params["TZID"]) ?: return null
|
||||||
val allDay = !dtStartRaw.contains('T')
|
val allDay = dtStart.params["VALUE"].equals("DATE", ignoreCase = true) || !dtStart.value.contains('T')
|
||||||
val endRaw = lines["DTEND"]
|
val dtEnd = props.firstOrNull { it.name == "DTEND" }
|
||||||
val end = if (endRaw != null) {
|
val end = dtEnd?.let { parseIcsInstant(it.value, it.params["TZID"]) }
|
||||||
parseIcsInstant(endRaw) ?: start.plusSeconds(if (allDay) 86400 else 3600)
|
?: start.plusSeconds(if (allDay) 86400 else 3600)
|
||||||
} else {
|
// каждый ATTENDEE — со своими параметрами (CN/PARTSTAT/ROLE), не схлопываем
|
||||||
start.plusSeconds(if (allDay) 86400 else 3600)
|
val attendees = props.filter { it.name == "ATTENDEE" }.mapNotNull { parseAttendee(it) }
|
||||||
}
|
val organizerProp = props.firstOrNull { it.name == "ORGANIZER" }
|
||||||
val attendees = lines.entries
|
val orgEmail = organizerProp?.value?.substringAfter("mailto:", organizerProp.value)?.trim().orEmpty()
|
||||||
.filter { it.key.startsWith("ATTENDEE") }
|
val orgName = organizerProp?.params?.get("CN")?.let(::unescape).orEmpty()
|
||||||
.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") }
|
||||||
@@ -173,52 +175,71 @@ 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 clean = value.replace("Z", "", ignoreCase = true).take(15)
|
val hasZ = value.endsWith("Z", ignoreCase = true)
|
||||||
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
val clean = value.trimEnd('Z', 'z').take(15)
|
||||||
.atZone(ZoneId.systemDefault()).toInstant()
|
val ldt = LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
|
||||||
|
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(ZoneId.systemDefault()).toInstant()
|
.atStartOfDay(paramZone ?: ZoneId.systemDefault()).toInstant()
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseLines(block: String): Map<String, String> {
|
/** Разбор ICS-строк в свойства с параметрами. Сворачивание строк уже снято в [unfold]. */
|
||||||
val map = mutableMapOf<String, String>()
|
private fun parseProps(block: String): List<IcsProp> {
|
||||||
unfold(block).lineSequence().forEach { line ->
|
val out = mutableListOf<IcsProp>()
|
||||||
val m = linePattern.matcher(line.trim())
|
unfold(block).lineSequence().forEach { raw ->
|
||||||
if (m.find()) {
|
val line = raw.trim()
|
||||||
val key = m.group(1)?.uppercase().orEmpty()
|
val colon = line.indexOf(':')
|
||||||
val value = m.group(2).orEmpty()
|
if (colon <= 0) return@forEach
|
||||||
map[key] = if (map.containsKey(key)) "${map[key]}\n$value" else value
|
val head = line.substring(0, colon) // NAME;PARAM=VAL;PARAM2=VAL
|
||||||
}
|
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 map
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseAttendeeLine(key: String, value: String): CalendarAttendeeData? {
|
private fun parseLines(block: String): Map<String, String> =
|
||||||
val email = value.substringAfter("mailto:", value).trim()
|
parseProps(block).associate { it.name to it.value }
|
||||||
|
|
||||||
|
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
|
||||||
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1)?.let(::unescape)
|
return CalendarAttendeeData(
|
||||||
val partStat = Regex("PARTSTAT=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "NEEDS-ACTION"
|
email = email,
|
||||||
val role = Regex("ROLE=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "REQ-PARTICIPANT"
|
displayName = prop.params["CN"]?.let(::unescape).orEmpty(),
|
||||||
val rsvp = !key.contains("RSVP=FALSE", ignoreCase = true)
|
partStat = prop.params["PARTSTAT"] ?: "NEEDS-ACTION",
|
||||||
return CalendarAttendeeData(email = email, displayName = cn.orEmpty(), partStat = partStat, role = role, rsvp = rsvp)
|
role = prop.params["ROLE"] ?: "REQ-PARTICIPANT",
|
||||||
}
|
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> {
|
||||||
@@ -265,8 +286,29 @@ 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(";", "\\;")
|
||||||
|
|
||||||
private fun unescape(text: String): String =
|
/** Single-pass: последовательные replace ломались на экранированном бэкслеше (`\\n` → перенос). */
|
||||||
text.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
|
private fun unescape(text: String): String {
|
||||||
|
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'"))
|
||||||
|
|||||||
@@ -69,6 +69,53 @@ object CardDavClient {
|
|||||||
return out.distinctBy { "${it.uid}|${it.email}" }
|
return out.distinctBy { "${it.uid}|${it.email}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дешёвая «подпись» состояния адресных книг — CTag коллекций (расширение
|
||||||
|
* CalendarServer, поддерживается Nextcloud), с фолбэком на sync-token.
|
||||||
|
* Один PROPFIND depth:1 без address-data. Если подпись не изменилась с прошлой
|
||||||
|
* синхронизации — контакты качать не нужно. null при ошибке/неподдержке → полный sync.
|
||||||
|
*/
|
||||||
|
fun collectionSignature(
|
||||||
|
client: OkHttpClient,
|
||||||
|
serverUrl: String,
|
||||||
|
userId: String,
|
||||||
|
): String? = runCatching {
|
||||||
|
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||||
|
<d:prop><cs:getctag/><d:sync-token/></d:prop>
|
||||||
|
</d:propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
val xml = propfind(client, base, depth = 1, body)
|
||||||
|
parseCollectionSignature(xml).ifBlank { null }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит PROPFIND-ответ в стабильную подпись «href=ctag» (по книгам, отсортировано).
|
||||||
|
* Regex-парсинг (как ICS в проекте) — чистая функция, тестируется без Android XmlPull.
|
||||||
|
*/
|
||||||
|
internal fun parseCollectionSignature(xml: String): String {
|
||||||
|
val entries = sortedSetOf<String>()
|
||||||
|
for (m in responseBlockPattern.findAll(xml)) {
|
||||||
|
val block = m.value
|
||||||
|
val href = hrefTagPattern.find(block)?.groupValues?.get(1)?.trim().orEmpty()
|
||||||
|
val tag = (
|
||||||
|
getctagPattern.find(block)?.groupValues?.get(1)
|
||||||
|
?: syncTokenPattern.find(block)?.groupValues?.get(1)
|
||||||
|
)?.trim().orEmpty()
|
||||||
|
if (href.isNotBlank() && tag.isNotBlank()) {
|
||||||
|
entries += "$href=$tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val responseBlockPattern = Regex("(?is)<(?:\\w+:)?response\\b.*?</(?:\\w+:)?response>")
|
||||||
|
private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)</(?:\\w+:)?href>")
|
||||||
|
private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)</(?:\\w+:)?getctag>")
|
||||||
|
private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)</(?:\\w+:)?sync-token>")
|
||||||
|
|
||||||
fun createContact(
|
fun createContact(
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
@@ -90,7 +137,6 @@ 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})")
|
||||||
}
|
}
|
||||||
@@ -163,7 +209,6 @@ 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,9 +27,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -46,9 +43,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -76,9 +70,6 @@ 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")
|
||||||
@@ -182,7 +173,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -197,7 +187,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -221,7 +210,6 @@ 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,25 +187,40 @@ object LoginFlowClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class CredentialParams(
|
internal 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? {
|
/**
|
||||||
val values = params.split('&')
|
* Разбор `server:...&user:...&password:...` (формат nc-login). Значения НЕ split('&'):
|
||||||
if (values.isEmpty() || values.size > 3) return null
|
* пароль может содержать `&` и `:`, а раньше `split('&')`+`size>3` его резал/ронял вход.
|
||||||
var server = ""
|
* Ищем маркеры `key:` (в начале или после `&`) и берём значение до следующего маркера.
|
||||||
var user = ""
|
*/
|
||||||
var password = ""
|
internal fun parseCredentialParams(params: String): CredentialParams? {
|
||||||
values.forEach { value ->
|
val keys = listOf("server", "user", "password")
|
||||||
when {
|
data class Marker(val key: String, val at: Int, val valueAt: Int)
|
||||||
value.startsWith("user:") -> user = decode(value.removePrefix("user:"))
|
val markers = mutableListOf<Marker>()
|
||||||
value.startsWith("server:") -> server = decode(value.removePrefix("server:"))
|
for (key in keys) {
|
||||||
value.startsWith("password:") -> password = decode(value.removePrefix("password:"))
|
if (params.startsWith("$key:")) markers += Marker(key, 0, key.length + 1)
|
||||||
|
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,23 +1,57 @@
|
|||||||
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 {
|
||||||
return OkHttpClient.Builder()
|
val key = "$username|$appPassword|$trustAllCerts|$callTimeoutSeconds|$readTimeoutSeconds|$throwOnUnauthorized"
|
||||||
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
|
return clients.getOrPut(key) {
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
base.newBuilder() // общий пул/диспатчер/кэш базового клиента
|
||||||
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
|
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
|
||||||
.applyUnsafeSslIfNeeded(trustAllCerts)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.addInterceptor(BasicAuthInterceptor(username, appPassword))
|
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
|
||||||
.build()
|
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||||
|
.addInterceptor(BasicAuthInterceptor(username, appPassword))
|
||||||
|
.apply { if (throwOnUnauthorized) addInterceptor(UnauthorizedInterceptor) }
|
||||||
|
.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. */
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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}")
|
||||||
}
|
}
|
||||||
@@ -86,7 +85,6 @@ 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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты подписи коллекций CardDAV (CTag/sync-token) — основа инкрементальной
|
||||||
|
* синхронизации: если подпись не изменилась, контакты не перекачиваются.
|
||||||
|
*/
|
||||||
|
class CardDavSignatureTest {
|
||||||
|
|
||||||
|
private val ns = """xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/""""
|
||||||
|
|
||||||
|
private fun body(vararg responses: String) =
|
||||||
|
"""<?xml version="1.0"?><d:multistatus $ns>${responses.joinToString("")}</d:multistatus>"""
|
||||||
|
|
||||||
|
private fun resp(href: String, ctag: String? = null, token: String? = null) = """
|
||||||
|
<d:response>
|
||||||
|
<d:href>$href</d:href>
|
||||||
|
<d:propstat><d:prop>
|
||||||
|
${ctag?.let { "<cs:getctag>$it</cs:getctag>" } ?: ""}
|
||||||
|
${token?.let { "<d:sync-token>$it</d:sync-token>" } ?: ""}
|
||||||
|
</d:prop></d:propstat>
|
||||||
|
</d:response>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test fun ctag_signature_is_stable_and_sorted() {
|
||||||
|
val a = CardDavClient.parseCollectionSignature(
|
||||||
|
body(resp("/dav/addressbooks/u/b1/", ctag = "111"), resp("/dav/addressbooks/u/b2/", ctag = "222")),
|
||||||
|
)
|
||||||
|
// порядок ответов не влияет на подпись
|
||||||
|
val b = CardDavClient.parseCollectionSignature(
|
||||||
|
body(resp("/dav/addressbooks/u/b2/", ctag = "222"), resp("/dav/addressbooks/u/b1/", ctag = "111")),
|
||||||
|
)
|
||||||
|
assertEquals(a, b)
|
||||||
|
assertTrue(a.contains("/dav/addressbooks/u/b1/=111"))
|
||||||
|
assertTrue(a.contains("/dav/addressbooks/u/b2/=222"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun ctag_change_changes_signature() {
|
||||||
|
val before = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "111")))
|
||||||
|
val after = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "999")))
|
||||||
|
assertNotEquals(before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun sync_token_fallback_when_no_ctag() {
|
||||||
|
val sig = CardDavClient.parseCollectionSignature(body(resp("/b1/", token = "http://sabre/sync/42")))
|
||||||
|
assertEquals("/b1/=http://sabre/sync/42", sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun empty_when_no_tags() {
|
||||||
|
assertEquals("", CardDavClient.parseCollectionSignature(body(resp("/b1/"))))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,9 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':core:auth')
|
implementation project(':core:auth')
|
||||||
implementation 'com.google.firebase:firebase-messaging:24.0.1'
|
implementation platform(libs.firebase.bom)
|
||||||
implementation 'androidx.core:core-ktx:1.15.0'
|
implementation libs.firebase.messaging
|
||||||
implementation 'androidx.core:core:1.15.0'
|
implementation libs.core.ktx
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation libs.core
|
||||||
|
implementation libs.coroutines.android
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?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>
|
||||||
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
||||||
/** Handles Decline on incoming Talk call notifications. */
|
/** Handles Decline and ring-timeout on incoming Talk call notifications. */
|
||||||
class F7CallActionReceiver : BroadcastReceiver() {
|
class F7CallActionReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != ACTION_DECLINE) return
|
|
||||||
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
|
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
|
||||||
F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
when (intent.action) {
|
||||||
|
ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
|
||||||
|
ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
|
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
|
||||||
|
const val ACTION_CALL_TIMEOUT = "ru.forbion.f7cloud.action.CALL_TIMEOUT"
|
||||||
const val EXTRA_NOTIFICATION_ID = "notificationId"
|
const val EXTRA_NOTIFICATION_ID = "notificationId"
|
||||||
const val EXTRA_ROOM_TOKEN = "roomToken"
|
const val EXTRA_ROOM_TOKEN = "roomToken"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package ru.forbion.f7cloud.core.push
|
package ru.forbion.f7cloud.core.push
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.Person
|
import androidx.core.app.Person
|
||||||
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
|
|||||||
private const val KEY_QUEUE = "queue"
|
private const val KEY_QUEUE = "queue"
|
||||||
private const val KEY_ACTIVE_TOKEN = "active_token"
|
private const val KEY_ACTIVE_TOKEN = "active_token"
|
||||||
private const val KEY_ACTIVE_AT = "active_at"
|
private const val KEY_ACTIVE_AT = "active_at"
|
||||||
|
private const val KEY_ACTIVE_CALL = "active_call"
|
||||||
const val ACTIVE_NOTIFICATION_ID = 5000
|
const val ACTIVE_NOTIFICATION_ID = 5000
|
||||||
|
private const val MISSED_NOTIFICATION_BASE = 5100
|
||||||
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
|
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Входящий звонит 30 с; дальше — авто-сброс и уведомление «Пропущенный звонок». */
|
||||||
|
private const val RING_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
|
/** Запас страховочного alarm поверх основного in-process таймера. */
|
||||||
|
private const val TIMEOUT_ALARM_SLACK_MS = 5_000L
|
||||||
|
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var timeoutRunnable: Runnable? = null
|
||||||
|
private val callEndedListeners = java.util.concurrent.CopyOnWriteArraySet<(String) -> Unit>()
|
||||||
|
|
||||||
|
/** Уведомляет UI (полноэкранный входящий), что активный звонок снят: принят/отклонён/протух. */
|
||||||
|
fun addCallEndedListener(listener: (String) -> Unit) {
|
||||||
|
callEndedListeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCallEndedListener(listener: (String) -> Unit) {
|
||||||
|
callEndedListeners.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyCallEnded(token: String) {
|
||||||
|
callEndedListeners.forEach { listener ->
|
||||||
|
runCatching { listener(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun enqueue(
|
fun enqueue(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (active.isEmpty()) {
|
if (active.isEmpty()) {
|
||||||
setActive(prefs, token)
|
|
||||||
val shown = showNotification(context, call, waiting = 0)
|
val shown = showNotification(context, call, waiting = 0)
|
||||||
if (!shown) {
|
if (shown) {
|
||||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
setActive(prefs, call)
|
||||||
|
scheduleTimeout(context, token)
|
||||||
}
|
}
|
||||||
return shown
|
return shown
|
||||||
}
|
}
|
||||||
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
||||||
|
var ended: String? = null
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val prefs = prefs(context)
|
val prefs = prefs(context)
|
||||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||||
@@ -88,36 +118,74 @@ object F7IncomingCallQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelTimeout(context)
|
||||||
cancelNotification(context)
|
cancelNotification(context)
|
||||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
clearActive(prefs)
|
||||||
|
ended = active.takeIf { it.isNotEmpty() }
|
||||||
if (queue.length() == 0) {
|
advanceQueueLocked(context, prefs, queue)
|
||||||
prefs.edit().remove(KEY_QUEUE).apply()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
|
||||||
val rest = JSONArray()
|
|
||||||
for (i in 1 until queue.length()) {
|
|
||||||
rest.put(queue.get(i))
|
|
||||||
}
|
|
||||||
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
|
||||||
if (showNotification(context, next, rest.length())) {
|
|
||||||
setActive(prefs, next.roomToken)
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
Log.w(TAG, "Failed to parse queued call", it)
|
|
||||||
prefs.edit().remove(KEY_QUEUE).apply()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ended?.let { notifyCallEnded(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
|
||||||
|
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
|
||||||
|
* Принятые/отклонённые звонки сюда не попадают — их снимает [dismissAndShowNext].
|
||||||
|
*/
|
||||||
|
fun timeoutActive(context: Context, roomToken: String?) {
|
||||||
|
var ended: String? = null
|
||||||
|
synchronized(lock) {
|
||||||
|
val prefs = prefs(context)
|
||||||
|
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||||
|
if (active.isEmpty()) return
|
||||||
|
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
|
||||||
|
|
||||||
|
val call = readActiveCall(prefs)
|
||||||
|
cancelTimeout(context)
|
||||||
|
cancelNotification(context)
|
||||||
|
clearActive(prefs)
|
||||||
|
ended = active
|
||||||
|
Log.i(TAG, "Call timed out (unanswered): $active")
|
||||||
|
if (call != null) {
|
||||||
|
showMissedNotification(context, call)
|
||||||
|
}
|
||||||
|
advanceQueueLocked(context, prefs, readQueue(prefs))
|
||||||
|
}
|
||||||
|
ended?.let { notifyCallEnded(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearAll(context: Context) {
|
fun clearAll(context: Context) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val prefs = prefs(context)
|
val prefs = prefs(context)
|
||||||
|
cancelTimeout(context)
|
||||||
cancelNotification(context)
|
cancelNotification(context)
|
||||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
|
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun advanceQueueLocked(
|
||||||
|
context: Context,
|
||||||
|
prefs: android.content.SharedPreferences,
|
||||||
|
queue: JSONArray,
|
||||||
|
) {
|
||||||
|
if (queue.length() == 0) {
|
||||||
|
prefs.edit().remove(KEY_QUEUE).apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||||
|
val rest = JSONArray()
|
||||||
|
for (i in 1 until queue.length()) {
|
||||||
|
rest.put(queue.get(i))
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||||
|
if (showNotification(context, next, rest.length())) {
|
||||||
|
setActive(prefs, next)
|
||||||
|
scheduleTimeout(context, next.roomToken)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "Failed to parse queued call", it)
|
||||||
|
prefs.edit().remove(KEY_QUEUE).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +309,8 @@ 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_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(genericCallPublic(context, intents.preview))
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
@@ -282,7 +351,8 @@ 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_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(genericCallPublic(context, intents.preview))
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
@@ -304,6 +374,21 @@ 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 ->
|
||||||
@@ -326,13 +411,106 @@ object F7IncomingCallQueue {
|
|||||||
private fun prefs(context: Context) =
|
private fun prefs(context: Context) =
|
||||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
private fun setActive(prefs: android.content.SharedPreferences, token: String) {
|
private fun setActive(prefs: android.content.SharedPreferences, call: PendingCall) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(KEY_ACTIVE_TOKEN, token)
|
.putString(KEY_ACTIVE_TOKEN, call.roomToken)
|
||||||
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
||||||
|
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
|
||||||
|
// показать «Пропущенный» с названием комнаты.
|
||||||
|
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearActive(prefs: android.content.SharedPreferences) {
|
||||||
|
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readActiveCall(prefs: android.content.SharedPreferences): PendingCall? {
|
||||||
|
val raw = prefs.getString(KEY_ACTIVE_CALL, null) ?: return null
|
||||||
|
return runCatching { PendingCall.fromJson(JSONObject(raw)) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleTimeout(context: Context, token: String) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
timeoutRunnable?.let(handler::removeCallbacks)
|
||||||
|
val runnable = Runnable { timeoutActive(app, token) }
|
||||||
|
timeoutRunnable = runnable
|
||||||
|
handler.postDelayed(runnable, RING_TIMEOUT_MS)
|
||||||
|
// Страховка: если процесс умрёт до срабатывания таймера, alarm поднимет его и
|
||||||
|
// заменит зависшее уведомление звонка на «Пропущенный». timeoutActive идемпотентен.
|
||||||
|
runCatching {
|
||||||
|
app.getSystemService(AlarmManager::class.java)?.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP,
|
||||||
|
System.currentTimeMillis() + RING_TIMEOUT_MS + TIMEOUT_ALARM_SLACK_MS,
|
||||||
|
timeoutAlarmIntent(app, token),
|
||||||
|
)
|
||||||
|
}.onFailure { Log.w(TAG, "Timeout alarm schedule failed", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelTimeout(context: Context) {
|
||||||
|
timeoutRunnable?.let(handler::removeCallbacks)
|
||||||
|
timeoutRunnable = null
|
||||||
|
runCatching {
|
||||||
|
context.applicationContext.getSystemService(AlarmManager::class.java)
|
||||||
|
?.cancel(timeoutAlarmIntent(context.applicationContext, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun timeoutAlarmIntent(context: Context, token: String?): PendingIntent {
|
||||||
|
val intent = Intent(context, F7CallActionReceiver::class.java).apply {
|
||||||
|
action = F7CallActionReceiver.ACTION_CALL_TIMEOUT
|
||||||
|
if (token != null) {
|
||||||
|
putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ACTIVE_NOTIFICATION_ID + 2,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMissedNotification(context: Context, call: PendingCall) {
|
||||||
|
F7NotificationChannels.ensureAll(context)
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val roomName = call.displayName.ifBlank { call.title }
|
||||||
|
val notificationId = MISSED_NOTIFICATION_BASE + kotlin.math.abs(call.roomToken.hashCode() % 1000)
|
||||||
|
val pending = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
notificationId,
|
||||||
|
Intent(launch).putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
|
||||||
|
?: android.R.drawable.sym_call_missed
|
||||||
|
// Локскрин — обезличенно (минимум информации, решение владельца); название комнаты
|
||||||
|
// видно после разблокировки.
|
||||||
|
val publicVersion = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
|
||||||
|
.setSmallIcon(iconRes)
|
||||||
|
.setContentTitle("F7cloud")
|
||||||
|
.setContentText(context.getString(R.string.call_missed_public))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.apply { if (pending != null) setContentIntent(pending) }
|
||||||
|
.build()
|
||||||
|
val notification = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
|
||||||
|
.setSmallIcon(iconRes)
|
||||||
|
.setContentTitle(context.getString(R.string.call_missed_title))
|
||||||
|
.setContentText(roomName)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(publicVersion)
|
||||||
|
.apply { if (pending != null) setContentIntent(pending) }
|
||||||
|
.build()
|
||||||
|
runCatching { manager.notify(notificationId, notification) }
|
||||||
|
.onFailure { Log.w(TAG, "Missed-call notification failed", it) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun touchActive(prefs: android.content.SharedPreferences) {
|
private fun touchActive(prefs: android.content.SharedPreferences) {
|
||||||
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
||||||
}
|
}
|
||||||
@@ -343,7 +521,7 @@ object F7IncomingCallQueue {
|
|||||||
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
|
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
|
||||||
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
|
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
|
||||||
Log.w(TAG, "Clearing stale active call: $active")
|
Log.w(TAG, "Clearing stale active call: $active")
|
||||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
clearActive(prefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ object F7NotificationChannels {
|
|||||||
setSound(sound, soundAttrs)
|
setSound(sound, soundAttrs)
|
||||||
}
|
}
|
||||||
setShowBadge(true)
|
setShowBadge(true)
|
||||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
// Приватно на локскрине: система покажет public-версию (generic), а не полный
|
||||||
|
// контент (минимум информации, решение владельца). Public-версия задаётся
|
||||||
|
// per-notification в F7PushNotificationHelper / F7IncomingCallQueue.
|
||||||
|
lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ 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)
|
||||||
@@ -56,6 +71,8 @@ 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)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<string name="call_action_accept">Принять</string>
|
<string name="call_action_accept">Принять</string>
|
||||||
<string name="call_action_decline">Отклонить</string>
|
<string name="call_action_decline">Отклонить</string>
|
||||||
<string name="incoming_call_subtitle">F7cloud звонок</string>
|
<string name="incoming_call_subtitle">F7cloud звонок</string>
|
||||||
|
<string name="call_missed_title">Пропущенный звонок</string>
|
||||||
|
<string name="call_missed_public">Вы пропустили звонок</string>
|
||||||
<plurals name="call_queue_waiting">
|
<plurals name="call_queue_waiting">
|
||||||
<item quantity="one">Ещё %d звонок в очереди</item>
|
<item quantity="one">Ещё %d звонок в очереди</item>
|
||||||
<item quantity="few">Ещё %d звонка в очереди</item>
|
<item quantity="few">Ещё %d звонка в очереди</item>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'java-library'
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
Раннер развёрнут на инфре B (act_runner 0.2.13 + Node 20 + Docker + systemd), диспетчеризация
|
||||||
|
Gitea работает (джобы доходят до раннера). **НО host-исполнение act_runner 0.2.13 в нашем
|
||||||
|
окружении сломано**: криво резолвит путь к шагам и JS-экшенам —
|
||||||
|
`No such file or directory` на `.cache/act/<id>/act/workflow/0.sh` и `MODULE_NOT_FOUND` на
|
||||||
|
`actions/checkout` (задвоение `hostexecutor/.cache/act`). Обойти на уровне workflow нельзя
|
||||||
|
(ломается сам запуск шага). Workflow **запаркован** (триггер `workflow_dispatch`), сервис
|
||||||
|
раннера остановлен и отключён от автозапуска — чтобы push'и не плодили падающие прогоны.
|
||||||
|
|
||||||
|
**Рабочий путь (когда вернёмся):** Docker-исполнение вместо host —
|
||||||
|
- кастомный образ с JDK 21 + Android SDK 36 (в дефолтном ubuntu их нет);
|
||||||
|
- volume для тёплого `~/.gradle` (vendor/WebRTC);
|
||||||
|
- раннер с меткой на docker-образ (не `:host`); в docker-режиме пути корректны, JS-экшены работают;
|
||||||
|
- регистрировать repo-токеном именно из **`f7cloud_mobile`** (id 24) — при отладке «repo-токены»
|
||||||
|
ошибочно регистрировали к чужому `f7cloud_client` (id 4) или в user-scope; в Gitea осталось
|
||||||
|
~5 дублей раннера `f7-b-android` (удалить в админке).
|
||||||
|
|
||||||
|
**Пока:** сборки и релизы — **вручную** на билд-машине (боевой ключ владельца из
|
||||||
|
`/root/.f7cloud-keys/`), исходники — `scripts/package-source.sh`, Release в Gitea — через API.
|
||||||
|
Что и делаем: релиз `v0.5.123` опубликован (APK + source) вручную.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Источник дизайна — 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).
|
||||||
@@ -27,15 +27,16 @@ 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 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation libs.coroutines.android
|
||||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
def composeBom = platform(libs.compose.bom)
|
||||||
implementation composeBom
|
implementation composeBom
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation libs.compose.ui
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation libs.compose.material3
|
||||||
implementation 'androidx.compose.foundation:foundation'
|
implementation libs.compose.foundation
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
implementation libs.lifecycle.viewmodel.compose
|
||||||
implementation 'androidx.activity:activity-compose:1.10.1'
|
implementation libs.activity.compose
|
||||||
implementation 'androidx.core:core-ktx:1.15.0'
|
implementation libs.core.ktx
|
||||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
implementation libs.coil.compose
|
||||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
implementation libs.coil.svg
|
||||||
|
testImplementation libs.junit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<?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 -> 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) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> 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<CalendarEventItem>, viewMode: CalendarViewMode, onViewMode: (CalendarViewMode) -> Unit, onEventClick: (CalendarEventItem) -> 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) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> 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 -> 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 -> st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.plusDays(1), visibleMonth = YearMonth.from(st.selectedDay.plusDays(1)))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.minusDays(14), visibleMonth = YearMonth.from(st.selectedDay.minusDays(14)))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.plusDays(14), visibleMonth = YearMonth.from(st.selectedDay.plusDays(14)))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.minusMonths(1), selectedDay = st.visibleMonth.minusMonths(1).atDay(1))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.plusMonths(1), selectedDay = st.visibleMonth.plusMonths(1).atDay(1))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.minusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.minusWeeks(1)))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))</ID>
|
||||||
|
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.YEAR -> 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: () -> Unit, )</ID>
|
||||||
|
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarNavButton(text: String, onClick: () -> 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,7 +46,6 @@ 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 {
|
||||||
@@ -77,7 +76,6 @@ 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 {
|
||||||
@@ -104,7 +102,6 @@ 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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +121,6 @@ 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,7 +9,6 @@ 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
|
||||||
@@ -388,46 +387,60 @@ 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()
|
||||||
|
// Живая тема (_calendar-month-view-mobile.css): месяц — белая карточка r12 с рамкой,
|
||||||
|
// внутри дни недели — круги 40 (#F5F5F5, 16/500) и ячейки-карточки #FDFDFD r4
|
||||||
|
// с тенью, зазор 4.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(F7Colors.Surface)
|
.background(Color.White)
|
||||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
|
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
|
||||||
.padding(8.dp),
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
weekDayLabels.forEach { label ->
|
weekDayLabels.forEach { label ->
|
||||||
Text(
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
text = label,
|
Box(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
textAlign = TextAlign.Center,
|
.size(40.dp)
|
||||||
style = MaterialTheme.typography.labelSmall,
|
.clip(CircleShape)
|
||||||
color = F7Colors.TextMuted,
|
.background(F7Colors.Grey2),
|
||||||
)
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
days.chunked(7).forEach { week ->
|
days.chunked(7).forEach { week ->
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
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 = selected,
|
selected = day == selectedDay,
|
||||||
hasEvents = hasEvents,
|
events = dayEvents,
|
||||||
preview = dayEvents.firstOrNull()?.summary.orEmpty(),
|
|
||||||
onClick = { onSelectDay(day) },
|
onClick = { onSelectDay(day) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
@@ -443,65 +456,88 @@ private fun CalendarDayCell(
|
|||||||
inMonth: Boolean,
|
inMonth: Boolean,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
hasEvents: Boolean,
|
events: List<CalendarEventItem>,
|
||||||
preview: String,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val bg = when {
|
// Живая тема: ячейка — карточка #FDFDFD r4 с тонкой тенью, высота 120, паддинг 4;
|
||||||
selected -> F7Colors.PrimaryLight
|
// номер дня 14/500 СПРАВА-сверху (чужой месяц — серый); «сегодня» — зелёная плашка
|
||||||
isToday -> F7Colors.PrimaryLight.copy(alpha = 0.55f)
|
// 40×24 r4 с белым числом; выбранный день подсвечиваем #ECF9DE (наша адаптация тапа).
|
||||||
else -> F7Colors.Surface
|
Column(
|
||||||
}
|
|
||||||
val borderColor = when {
|
|
||||||
selected -> F7Colors.Primary
|
|
||||||
isToday -> F7Colors.PrimaryDark
|
|
||||||
else -> F7Colors.BorderLight
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.aspectRatio(1f)
|
.height(120.dp)
|
||||||
.padding(2.dp)
|
.shadow(
|
||||||
.clip(RoundedCornerShape(8.dp))
|
elevation = 1.dp,
|
||||||
.background(bg)
|
shape = RoundedCornerShape(4.dp),
|
||||||
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
|
spotColor = F7Colors.Border,
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(2.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = day.toString(),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
color = when {
|
|
||||||
!inMonth -> F7Colors.TextMuted
|
|
||||||
selected -> F7Colors.PrimaryDark
|
|
||||||
else -> F7Colors.TextPrimary
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if (hasEvents && inMonth && preview.isNotBlank()) {
|
.clip(RoundedCornerShape(4.dp))
|
||||||
Text(
|
.background(if (selected) F7Colors.PrimaryLight else Color(0xFFFDFDFD))
|
||||||
preview,
|
.clickable(onClick = onClick)
|
||||||
style = MaterialTheme.typography.labelSmall,
|
.padding(4.dp),
|
||||||
color = F7Colors.PrimaryDark,
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
maxLines = 1,
|
) {
|
||||||
overflow = TextOverflow.Ellipsis,
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
modifier = Modifier.padding(top = 1.dp),
|
if (isToday) {
|
||||||
)
|
|
||||||
} else if (hasEvents && inMonth) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 2.dp)
|
.width(40.dp)
|
||||||
.size(5.dp)
|
.height(24.dp)
|
||||||
.clip(CircleShape)
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(F7Colors.Primary),
|
.background(F7Colors.Primary),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = day.toString(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = day.toString(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (inMonth) F7Colors.TextPrimary else F7Colors.TextSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
events.take(3).forEach { event ->
|
||||||
|
CalendarDayEventPill(event.summary.ifBlank { "Событие" }, muted = !inMonth)
|
||||||
|
}
|
||||||
|
if (events.size > 3) {
|
||||||
|
Text(
|
||||||
|
"+${events.size - 3}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = F7Colors.Primary,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CalendarDayEventPill(text: String, muted: Boolean = false) {
|
||||||
|
// Живая тема: пилюля события — фон #ECF9DE, рамка #70B62B, r4, текст 10/12;
|
||||||
|
// вне текущего месяца — серые с белым текстом.
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(if (muted) F7Colors.TextSecondary else F7Colors.PrimaryLight)
|
||||||
|
.border(1.dp, if (muted) F7Colors.TextSecondary else F7Colors.Primary, RoundedCornerShape(4.dp))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
lineHeight = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (muted) Color.White else F7Colors.TextPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarWeekView(
|
fun CalendarWeekView(
|
||||||
selectedDay: LocalDate,
|
selectedDay: LocalDate,
|
||||||
@@ -793,12 +829,14 @@ fun CalendarEventRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarEventChip(event: CalendarEventItem, onClick: () -> Unit) {
|
private fun CalendarEventChip(event: CalendarEventItem, onClick: () -> Unit) {
|
||||||
|
// Живая тема: пилюля события — #ECF9DE с рамкой #70B62B, r4, текст 12/16
|
||||||
Text(
|
Text(
|
||||||
event.summary,
|
event.summary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(4.dp))
|
||||||
.background(F7Colors.Primary.copy(alpha = 0.15f))
|
.background(F7Colors.PrimaryLight)
|
||||||
|
.border(1.dp, F7Colors.Primary, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -1057,7 +1095,13 @@ fun CalendarEventDetailSheet(
|
|||||||
.padding(bottom = 28.dp),
|
.padding(bottom = 28.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(event.summary, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
// Живая тема: заголовки модалок 18/600
|
||||||
|
Text(
|
||||||
|
event.summary,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
CalendarDetailRow(
|
CalendarDetailRow(
|
||||||
iconUrl = "$base/themes/forbion/images/calendar/event-clock.svg",
|
iconUrl = "$base/themes/forbion/images/calendar/event-clock.svg",
|
||||||
label = "Время",
|
label = "Время",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.calendar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дисковый кэш «сырых» REPORT-ответов CalDAV (события по диапазону), ключуемый
|
||||||
|
* CTag'ом календаря: CTag не изменился → события читаются с диска, сеть не трогаем.
|
||||||
|
*
|
||||||
|
* Храним именно сырой XML (а не сериализованные модели) — переиспользуем боевой
|
||||||
|
* парсер CalDavClient и не заводим хрупкий JSON-маппинг ~20 полей события.
|
||||||
|
* Формат файла: первая строка — CTag на момент загрузки, дальше XML.
|
||||||
|
*/
|
||||||
|
class CalendarEventsCache(private val dir: File) {
|
||||||
|
|
||||||
|
constructor(context: Context) : this(File(context.cacheDir, "caldav_events"))
|
||||||
|
|
||||||
|
/** XML событий, если кэш есть и его CTag совпадает с текущим; иначе null. */
|
||||||
|
fun get(accountKey: String, calendarHref: String, rangeKey: String, ctag: String): String? {
|
||||||
|
if (ctag.isBlank()) return null
|
||||||
|
val f = entryFile(accountKey, calendarHref, rangeKey)
|
||||||
|
if (!f.isFile) return null
|
||||||
|
return runCatching {
|
||||||
|
val text = f.readText()
|
||||||
|
val nl = text.indexOf('\n')
|
||||||
|
if (nl > 0 && text.substring(0, nl) == ctag) text.substring(nl + 1) else null
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(accountKey: String, calendarHref: String, rangeKey: String, ctag: String, xml: String) {
|
||||||
|
if (ctag.isBlank()) return
|
||||||
|
runCatching {
|
||||||
|
dir.mkdirs()
|
||||||
|
entryFile(accountKey, calendarHref, rangeKey).writeText("$ctag\n$xml")
|
||||||
|
prune()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Чистка устаревших записей (диапазоны уезжают со временем — файлы копятся). */
|
||||||
|
private fun prune(maxAgeMillis: Long = MAX_AGE_MILLIS) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
dir.listFiles()?.forEach { f ->
|
||||||
|
if (now - f.lastModified() > maxAgeMillis) f.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryFile(accountKey: String, calendarHref: String, rangeKey: String): File =
|
||||||
|
File(dir, md5("$accountKey|$calendarHref|$rangeKey") + ".xml")
|
||||||
|
|
||||||
|
private fun md5(s: String): String =
|
||||||
|
MessageDigest.getInstance("MD5").digest(s.toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_AGE_MILLIS = 30L * 24 * 3600 * 1000 // 30 дней
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import java.time.ZoneOffset
|
|||||||
|
|
||||||
class CalendarRepository(
|
class CalendarRepository(
|
||||||
private val apiClient: CalendarApiClient = CalendarApiClient(),
|
private val apiClient: CalendarApiClient = CalendarApiClient(),
|
||||||
|
// Кэш событий по CTag (null — без кэша, поведение как раньше)
|
||||||
|
private val eventsCache: CalendarEventsCache? = null,
|
||||||
) {
|
) {
|
||||||
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
|
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ class CalendarRepository(
|
|||||||
val ctx = openDavContext(session)
|
val ctx = openDavContext(session)
|
||||||
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
|
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||||
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||||
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs)
|
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs, accountKey(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
|
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
|
||||||
@@ -210,13 +212,25 @@ class CalendarRepository(
|
|||||||
rangeStart: Instant,
|
rangeStart: Instant,
|
||||||
rangeEnd: Instant,
|
rangeEnd: Instant,
|
||||||
visibleHrefs: Set<String>,
|
visibleHrefs: Set<String>,
|
||||||
|
accountKey: String,
|
||||||
): List<CalendarEventItem> {
|
): List<CalendarEventItem> {
|
||||||
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
|
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
|
||||||
|
val rangeKey = "${rangeStart.epochSecond}-${rangeEnd.epochSecond}"
|
||||||
val events = mutableListOf<DavEvent>()
|
val events = mutableListOf<DavEvent>()
|
||||||
var successCount = 0
|
var successCount = 0
|
||||||
var lastError: String? = null
|
var lastError: String? = null
|
||||||
for (cal in calendars.take(12)) {
|
for (cal in calendars.take(12)) {
|
||||||
runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) }
|
runCatching {
|
||||||
|
// CTag календаря не менялся → сырой REPORT-ответ берём с диска, сеть не трогаем
|
||||||
|
val cachedXml = eventsCache?.get(accountKey, cal.href, rangeKey, cal.ctag)
|
||||||
|
if (cachedXml != null) {
|
||||||
|
CalDavClient.parseEventsXml(cachedXml, cal)
|
||||||
|
} else {
|
||||||
|
val xml = CalDavClient.queryEventsRawXml(ctx.client, cal, rangeStart, rangeEnd)
|
||||||
|
eventsCache?.put(accountKey, cal.href, rangeKey, cal.ctag, xml)
|
||||||
|
CalDavClient.parseEventsXml(xml, cal)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onSuccess { successCount++; events += it }
|
.onSuccess { successCount++; events += it }
|
||||||
.onFailure { lastError = it.message }
|
.onFailure { lastError = it.message }
|
||||||
}
|
}
|
||||||
@@ -226,6 +240,9 @@ class CalendarRepository(
|
|||||||
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
|
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun accountKey(session: AuthSession): String =
|
||||||
|
"${session.serverUrl}|${session.username}"
|
||||||
|
|
||||||
private fun DavEvent.toItem() = CalendarEventItem(
|
private fun DavEvent.toItem() = CalendarEventItem(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
href = href,
|
href = href,
|
||||||
|
|||||||
@@ -343,7 +343,6 @@ 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ data class CalendarUiState(
|
|||||||
|
|
||||||
class CalendarViewModel(
|
class CalendarViewModel(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val repository: CalendarRepository = CalendarRepository(),
|
private val repository: CalendarRepository = CalendarRepository(
|
||||||
|
eventsCache = CalendarEventsCache(context.applicationContext),
|
||||||
|
),
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val deviceClient = DeviceCalendarClient(appContext)
|
private val deviceClient = DeviceCalendarClient(appContext)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.calendar
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кэш «сырых» REPORT-ответов CalDAV: CTag совпал → XML с диска; изменился/пусто → null.
|
||||||
|
*/
|
||||||
|
class CalendarEventsCacheTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val tmp = TemporaryFolder()
|
||||||
|
|
||||||
|
private fun cache() = CalendarEventsCache(tmp.newFolder("caldav"))
|
||||||
|
|
||||||
|
@Test fun hit_when_ctag_matches() {
|
||||||
|
val c = cache()
|
||||||
|
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml>events</xml>")
|
||||||
|
assertEquals("<xml>events</xml>", c.get("acc", "/cal/personal/", "r1", "ctag-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun miss_when_ctag_changed() {
|
||||||
|
val c = cache()
|
||||||
|
c.put("acc", "/cal/personal/", "r1", "ctag-1", "<xml/>")
|
||||||
|
assertNull(c.get("acc", "/cal/personal/", "r1", "ctag-2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun miss_on_blank_ctag_and_unknown_range() {
|
||||||
|
val c = cache()
|
||||||
|
c.put("acc", "/cal/personal/", "r1", "", "<xml/>") // пустой ctag не кэшируется
|
||||||
|
assertNull(c.get("acc", "/cal/personal/", "r1", ""))
|
||||||
|
assertNull(c.get("acc", "/cal/personal/", "other-range", "ctag-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun entries_are_isolated_by_account_calendar_range() {
|
||||||
|
val c = cache()
|
||||||
|
c.put("acc1", "/cal/a/", "r1", "t", "<a1/>")
|
||||||
|
c.put("acc2", "/cal/a/", "r1", "t", "<a2/>")
|
||||||
|
c.put("acc1", "/cal/b/", "r1", "t", "<b1/>")
|
||||||
|
assertEquals("<a1/>", c.get("acc1", "/cal/a/", "r1", "t"))
|
||||||
|
assertEquals("<a2/>", c.get("acc2", "/cal/a/", "r1", "t"))
|
||||||
|
assertEquals("<b1/>", c.get("acc1", "/cal/b/", "r1", "t"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun multiline_xml_preserved() {
|
||||||
|
val c = cache()
|
||||||
|
val xml = "<multistatus>\n <response>\n <href>/e1.ics</href>\n </response>\n</multistatus>"
|
||||||
|
c.put("acc", "/cal/", "r", "ct", xml)
|
||||||
|
assertEquals(xml, c.get("acc", "/cal/", "r", "ct"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation libs.coroutines.android
|
||||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
def composeBom = platform(libs.compose.bom)
|
||||||
implementation composeBom
|
implementation composeBom
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation libs.compose.ui
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation libs.compose.material3
|
||||||
implementation 'androidx.compose.foundation:foundation'
|
implementation libs.compose.foundation
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
implementation libs.lifecycle.viewmodel.compose
|
||||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
implementation libs.coil.compose
|
||||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
implementation libs.coil.svg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<SmellBaseline>
|
||||||
|
<ManuallySuppressedIssues/>
|
||||||
|
<CurrentIssues>
|
||||||
|
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force && !needsPhotoBackfill && !needsDetailsBackfill && System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS</ID>
|
||||||
|
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, )</ID>
|
||||||
|
<ID>MultiLineIfElse:ContactDetailSheet.kt$"https://$it"</ID>
|
||||||
|
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
|
||||||
|
</CurrentIssues>
|
||||||
|
</SmellBaseline>
|
||||||
@@ -74,6 +74,18 @@ class ContactsRepository(context: Context) {
|
|||||||
) {
|
) {
|
||||||
return dao.getAll(key).map { it.toItem() }
|
return dao.getAll(key).map { it.toItem() }
|
||||||
}
|
}
|
||||||
|
// Дешёвая проверка CTag: если адресные книги не менялись (и бэкфиллы сделаны) —
|
||||||
|
// полный PROPFIND всех vCard не нужен, отдаём кэш. Это ускоряет pull-to-refresh.
|
||||||
|
val needsBackfill = needsPhotoBackfill || needsDetailsBackfill
|
||||||
|
val client = authedClient(session)
|
||||||
|
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||||
|
val currentSig = CardDavClient.collectionSignature(client, session.serverUrl, userId)
|
||||||
|
if (!needsBackfill && currentSig != null &&
|
||||||
|
currentSig == prefs.getString(ctagKey(key), null)
|
||||||
|
) {
|
||||||
|
prefs.edit().putLong(lastSyncKey(key), System.currentTimeMillis()).apply()
|
||||||
|
return dao.getAll(key).map { it.toItem() }
|
||||||
|
}
|
||||||
val remote = fetchRemoteContacts(session)
|
val remote = fetchRemoteContacts(session)
|
||||||
val entities = remote.map { contact ->
|
val entities = remote.map { contact ->
|
||||||
ContactEntity(
|
ContactEntity(
|
||||||
@@ -95,11 +107,12 @@ class ContactsRepository(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
dao.replaceAll(key, entities)
|
dao.replaceAll(key, entities)
|
||||||
prefs.edit()
|
val editor = prefs.edit()
|
||||||
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
||||||
.putBoolean(photoSyncDoneKey(key), true)
|
.putBoolean(photoSyncDoneKey(key), true)
|
||||||
.putBoolean(detailsSyncDoneKey(key), true)
|
.putBoolean(detailsSyncDoneKey(key), true)
|
||||||
.apply()
|
if (currentSig != null) editor.putString(ctagKey(key), currentSig) else editor.remove(ctagKey(key))
|
||||||
|
editor.apply()
|
||||||
return entities.map { it.toItem() }
|
return entities.map { it.toItem() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +203,8 @@ class ContactsRepository(context: Context) {
|
|||||||
|
|
||||||
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
|
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
|
||||||
|
|
||||||
|
private fun ctagKey(accountKey: String) = "ctag_$accountKey"
|
||||||
|
|
||||||
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
|
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
|
||||||
|
|
||||||
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
|
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import androidx.compose.foundation.text.BasicTextField
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -48,8 +50,10 @@ 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
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactsScreen(
|
fun ContactsScreen(
|
||||||
session: AuthSession,
|
session: AuthSession,
|
||||||
@@ -75,27 +79,40 @@ fun ContactsScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
loading = state.loading && state.contacts.isEmpty(),
|
loading = state.loading && state.contacts.isEmpty(),
|
||||||
error = state.error,
|
error = state.error,
|
||||||
|
onErrorRetry = { vm.refresh(session, force = true) },
|
||||||
) {
|
) {
|
||||||
ContactsSearchBar(
|
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
|
||||||
serverUrl = session.serverUrl,
|
// панели, теперь — из шапки, панель стала навигационной)
|
||||||
query = state.searchQuery,
|
Row(
|
||||||
onQueryChange = vm::setSearchQuery,
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
if (state.syncing && state.contacts.isNotEmpty()) {
|
.padding(bottom = 8.dp),
|
||||||
Text(
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
"Обновление…",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
) {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
ContactsSearchBar(
|
||||||
color = F7Colors.TextSecondary,
|
serverUrl = session.serverUrl,
|
||||||
|
query = state.searchQuery,
|
||||||
|
onQueryChange = vm::setSearchQuery,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
|
||||||
}
|
}
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
PullToRefreshBox(
|
||||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
isRefreshing = state.syncing && state.contacts.isNotEmpty(),
|
||||||
ContactListRow(
|
onRefresh = { vm.refresh(session, force = true) },
|
||||||
contact = contact,
|
modifier = Modifier
|
||||||
onClick = { vm.openContact(contact) },
|
.weight(1f)
|
||||||
)
|
.fillMaxWidth(),
|
||||||
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||||
|
ContactListRow(
|
||||||
|
contact = contact,
|
||||||
|
onClick = { vm.openContact(contact) },
|
||||||
|
)
|
||||||
|
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,17 +281,19 @@ fun ContactListRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
// Живая тема: имя 16/500, детали 14/500 серым
|
||||||
Text(
|
Text(
|
||||||
contact.displayName.ifBlank { contact.email },
|
contact.displayName.ifBlank { contact.email },
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (contact.email.isNotBlank() && contact.displayName.isNotBlank()) {
|
if (contact.email.isNotBlank() && contact.displayName.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
contact.email,
|
contact.email,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = F7Colors.TextSecondary,
|
color = F7Colors.TextSecondary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -282,7 +301,7 @@ fun ContactListRow(
|
|||||||
} else if (contact.phone.isNotBlank()) {
|
} else if (contact.phone.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
contact.phone,
|
contact.phone,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = F7Colors.TextSecondary,
|
color = F7Colors.TextSecondary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -297,12 +316,11 @@ 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))
|
||||||
|
|||||||
@@ -99,14 +99,16 @@ class ContactsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(session: AuthSession, showLoading: Boolean = false) {
|
fun refresh(session: AuthSession, showLoading: Boolean = false, force: Boolean = false) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
if (showLoading && _state.value.contacts.isEmpty()) {
|
if (showLoading && _state.value.contacts.isEmpty()) {
|
||||||
_state.update { it.copy(loading = true, error = null) }
|
_state.update { it.copy(loading = true, error = null) }
|
||||||
} else {
|
} else {
|
||||||
_state.update { it.copy(syncing = true, error = null) }
|
_state.update { it.copy(syncing = true, error = null) }
|
||||||
}
|
}
|
||||||
runCatching { repository.syncContacts(session) }
|
// force (pull-to-refresh) с CTag-проверкой дешёвый: 1 лёгкий PROPFIND,
|
||||||
|
// полная закачка vCard — только если адресные книги реально менялись
|
||||||
|
runCatching { repository.syncContacts(session, force = force) }
|
||||||
.onFailure { t ->
|
.onFailure { t ->
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|||||||
@@ -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 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation libs.coroutines.android
|
||||||
implementation 'org.json:json:20240303'
|
implementation libs.json
|
||||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
def composeBom = platform(libs.compose.bom)
|
||||||
implementation composeBom
|
implementation composeBom
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation libs.compose.ui
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation libs.compose.material3
|
||||||
implementation 'androidx.compose.foundation:foundation'
|
implementation libs.compose.foundation
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
implementation libs.lifecycle.viewmodel.compose
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<SmellBaseline>
|
||||||
|
<ManuallySuppressedIssues/>
|
||||||
|
<CurrentIssues>
|
||||||
|
<ID>NoUnusedImports:DeckRepository.kt$ru.forbion.f7cloud.feature.deck.DeckRepository.kt</ID>
|
||||||
|
</CurrentIssues>
|
||||||
|
</SmellBaseline>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.deck
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun DeckCardSheet(
|
||||||
|
card: DeckCard,
|
||||||
|
boardLabels: List<DeckLabel>,
|
||||||
|
stacks: List<DeckStack>,
|
||||||
|
canEdit: Boolean,
|
||||||
|
busy: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onToggleDone: () -> Unit,
|
||||||
|
onSave: (title: String, description: String, duedate: String?) -> Unit,
|
||||||
|
onToggleLabel: (DeckLabel) -> Unit,
|
||||||
|
onMove: (targetStackId: Int) -> Unit,
|
||||||
|
onArchive: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
var title by remember(card.id) { mutableStateOf(card.title) }
|
||||||
|
var description by remember(card.id) { mutableStateOf(card.description) }
|
||||||
|
// duedate храним как ISO-строку (как отдаёт/принимает Deck), null = без срока
|
||||||
|
var duedate by remember(card.id) { mutableStateOf(card.duedate) }
|
||||||
|
var datePickerOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(0.96f).heightIn(max = 680.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = Color.White,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// Done + заголовок
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Checkbox(
|
||||||
|
checked = card.done,
|
||||||
|
onCheckedChange = { if (canEdit) onToggleDone() },
|
||||||
|
enabled = canEdit && !busy,
|
||||||
|
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (card.done) "Выполнена" else "Не выполнена",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
F7OutlinedField(value = title, onValueChange = { title = it }, label = "Название")
|
||||||
|
F7OutlinedField(value = description, onValueChange = { description = it }, label = "Описание", minLines = 3)
|
||||||
|
|
||||||
|
// Срок
|
||||||
|
Text("Срок", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(F7Colors.SurfaceMuted)
|
||||||
|
.clickable(enabled = canEdit) { datePickerOpen = true }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
duedate?.let { formatDeckDue(it) } ?: "Без срока",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (duedate != null) F7Colors.TextPrimary else F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
if (duedate != null) {
|
||||||
|
Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метки доски
|
||||||
|
if (boardLabels.isNotEmpty()) {
|
||||||
|
Text("Метки", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
boardLabels.forEach { label ->
|
||||||
|
val active = card.labels.any { it.id == label.id }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(
|
||||||
|
(parseDeckColor(label.color) ?: F7Colors.Primary)
|
||||||
|
.copy(alpha = if (active) 0.35f else 0.12f),
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
if (active) 2.dp else 1.dp,
|
||||||
|
parseDeckColor(label.color) ?: F7Colors.Primary,
|
||||||
|
RoundedCornerShape(4.dp),
|
||||||
|
)
|
||||||
|
.clickable(enabled = canEdit && !busy) { onToggleLabel(label) }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
|
) {
|
||||||
|
Text(label.title, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переместить в колонку
|
||||||
|
if (canEdit && stacks.size > 1) {
|
||||||
|
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
stacks.forEach { stack ->
|
||||||
|
val current = stack.id == card.stackId
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(100.dp))
|
||||||
|
.background(if (current) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
|
||||||
|
.clickable(enabled = !current && !busy) { onMove(stack.id) }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(stack.title, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canEdit) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = if (busy) "…" else "Сохранить",
|
||||||
|
onClick = { onSave(title, description, duedate) },
|
||||||
|
enabled = !busy && title.isNotBlank(),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
F7SecondaryButton("В архив", onClick = onArchive, enabled = !busy, modifier = Modifier.weight(1f))
|
||||||
|
F7SecondaryButton("Удалить", onClick = onDelete, enabled = !busy, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datePickerOpen) {
|
||||||
|
val initMillis = duedate?.let { parseDeckDueMillis(it) }
|
||||||
|
?: System.currentTimeMillis()
|
||||||
|
val pickerState = rememberDatePickerState(initialSelectedDateMillis = initMillis)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { datePickerOpen = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
pickerState.selectedDateMillis?.let { millis ->
|
||||||
|
// Deck принимает ISO-8601 (в полдень UTC достаточно для «дня»)
|
||||||
|
val instant = Instant.ofEpochMilli(millis)
|
||||||
|
duedate = DateTimeFormatter.ISO_INSTANT.format(instant)
|
||||||
|
}
|
||||||
|
datePickerOpen = false
|
||||||
|
}) { Text("ОК", color = F7Colors.Primary) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { datePickerOpen = false }) { Text("Отмена", color = F7Colors.TextSecondary) }
|
||||||
|
},
|
||||||
|
) { DatePicker(state = pickerState) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeckTextPromptDialog(
|
||||||
|
title: String,
|
||||||
|
label: String,
|
||||||
|
confirmText: String,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
F7OutlinedField(value = text, onValueChange = { text = it }, label = label)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = confirmText,
|
||||||
|
onClick = { onConfirm(text) },
|
||||||
|
enabled = text.isNotBlank(),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
|
||||||
|
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
|
||||||
|
Instant.parse(iso).toEpochMilli()
|
||||||
|
}.getOrElse {
|
||||||
|
runCatching {
|
||||||
|
java.time.OffsetDateTime.parse(iso).toInstant().toEpochMilli()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отображение срока: «12 мар». */
|
||||||
|
internal fun formatDeckDue(iso: String): String {
|
||||||
|
val millis = parseDeckDueMillis(iso) ?: return iso.take(10)
|
||||||
|
val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
|
||||||
|
return date.format(DateTimeFormatter.ofPattern("d MMM"))
|
||||||
|
}
|
||||||
@@ -1,92 +1,228 @@
|
|||||||
package ru.forbion.f7cloud.feature.deck
|
package ru.forbion.f7cloud.feature.deck
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck через внутренний фронт-API (`/index.php/apps/deck/api/v1.0`) — тот же, что у веб-версии.
|
||||||
|
* Пишущие запросы авторизуются app-password + OCS-APIRequest (CSRF при app-password не требуется).
|
||||||
|
*/
|
||||||
class DeckRepository {
|
class DeckRepository {
|
||||||
private fun apiBase(session: AuthSession): String {
|
private fun apiBase(session: AuthSession): String =
|
||||||
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
"${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
||||||
}
|
|
||||||
|
private fun client(session: AuthSession): OkHttpClient =
|
||||||
|
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||||
|
|
||||||
|
// --- Чтение ---
|
||||||
|
|
||||||
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
|
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
|
||||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
val json = getJson(client(session), "${apiBase(session)}/boards")
|
||||||
val json = getJson(client, "${apiBase(session)}/boards")
|
val array = json as? JSONArray ?: (json as? JSONObject)?.let { JSONArray().put(it) } ?: JSONArray()
|
||||||
val array = when (json) {
|
|
||||||
is JSONArray -> json
|
|
||||||
is JSONObject -> JSONArray().put(json)
|
|
||||||
else -> JSONArray()
|
|
||||||
}
|
|
||||||
val out = mutableListOf<DeckBoard>()
|
val out = mutableListOf<DeckBoard>()
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val board = array.optJSONObject(i) ?: continue
|
val board = array.optJSONObject(i) ?: continue
|
||||||
val id = board.optInt("id", 0)
|
val id = board.optInt("id", 0)
|
||||||
val title = board.optString("title")
|
if (id > 0 && !board.optBoolean("archived", false)) {
|
||||||
if (id > 0 && title.isNotBlank()) {
|
out += DeckBoard(
|
||||||
out += DeckBoard(id = id, title = title, color = board.optString("color"))
|
id = id,
|
||||||
|
title = board.optString("title"),
|
||||||
|
color = board.optString("color"),
|
||||||
|
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
|
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
|
||||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
val json = getJson(client(session), "${apiBase(session)}/cards/$cardId") as JSONObject
|
||||||
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
|
|
||||||
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
|
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
|
||||||
if (boardId <= 0) error("Карточка не найдена")
|
if (boardId <= 0) error("Карточка не найдена")
|
||||||
return DeckCardDetail(
|
return DeckCardDetail(cardId = cardId, boardId = boardId, title = json.optString("title"))
|
||||||
cardId = cardId,
|
|
||||||
boardId = boardId,
|
|
||||||
title = json.optString("title"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
||||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
val c = client(session)
|
||||||
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks")
|
// Доска-объект (метки/права) — НЕ критична: если запрос упадёт, доску всё равно
|
||||||
val stacksArray = when (stacksJson) {
|
// открываем со стеками (раньше был баг — сбой board#read рушил всю загрузку).
|
||||||
is JSONArray -> stacksJson
|
var boardTitle = ""
|
||||||
else -> JSONArray()
|
var labels = emptyList<DeckLabel>()
|
||||||
}
|
var canEdit = true
|
||||||
|
runCatching { getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject }
|
||||||
|
.onSuccess { board ->
|
||||||
|
boardTitle = board.optString("title")
|
||||||
|
labels = parseLabels(board.optJSONArray("labels"))
|
||||||
|
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
|
||||||
|
}
|
||||||
|
val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
|
||||||
val stacks = mutableListOf<DeckStack>()
|
val stacks = mutableListOf<DeckStack>()
|
||||||
for (i in 0 until stacksArray.length()) {
|
for (i in 0 until stacksJson.length()) {
|
||||||
val stack = stacksArray.optJSONObject(i) ?: continue
|
val stack = stacksJson.optJSONObject(i) ?: continue
|
||||||
val stackId = stack.optInt("id", 0)
|
val stackId = stack.optInt("id", 0)
|
||||||
val title = stack.optString("title")
|
if (stackId <= 0) continue
|
||||||
val cards = mutableListOf<DeckCard>()
|
|
||||||
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
|
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
|
||||||
for (c in 0 until cardsArray.length()) {
|
val cards = mutableListOf<DeckCard>()
|
||||||
val card = cardsArray.optJSONObject(c) ?: continue
|
for (cIdx in 0 until cardsArray.length()) {
|
||||||
val cardTitle = card.optString("title")
|
cardsArray.optJSONObject(cIdx)?.let { cards += parseCard(it, stackId) }
|
||||||
if (cardTitle.isNotBlank()) {
|
|
||||||
cards += DeckCard(
|
|
||||||
id = card.optInt("id", 0),
|
|
||||||
title = cardTitle,
|
|
||||||
done = card.has("done") && !card.isNull("done"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (stackId > 0) {
|
|
||||||
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
|
|
||||||
}
|
}
|
||||||
|
stacks += DeckStack(
|
||||||
|
id = stackId,
|
||||||
|
boardId = boardId,
|
||||||
|
title = stack.optString("title").ifBlank { "Колонка" },
|
||||||
|
order = stack.optInt("order", i),
|
||||||
|
cards = cards.sortedBy { it.order },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return DeckBoardDetail(boardId = boardId, stacks = stacks)
|
return DeckBoardDetail(
|
||||||
|
boardId = boardId,
|
||||||
|
title = boardTitle,
|
||||||
|
canEdit = canEdit,
|
||||||
|
labels = labels,
|
||||||
|
stacks = stacks.sortedBy { it.order },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
|
private fun parseCard(card: JSONObject, stackId: Int): DeckCard = DeckCard(
|
||||||
|
id = card.optInt("id", 0),
|
||||||
|
title = card.optString("title"),
|
||||||
|
done = !card.isNull("done") && card.optString("done").isNotBlank(),
|
||||||
|
description = card.optString("description"),
|
||||||
|
duedate = card.optString("duedate").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
order = card.optInt("order", 0),
|
||||||
|
stackId = stackId,
|
||||||
|
labels = parseLabels(card.optJSONArray("labels")),
|
||||||
|
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
|
||||||
|
if (array == null) return emptyList()
|
||||||
|
val out = mutableListOf<DeckLabel>()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val l = array.optJSONObject(i) ?: continue
|
||||||
|
val id = l.optInt("id", 0)
|
||||||
|
if (id > 0) out += DeckLabel(id = id, title = l.optString("title"), color = l.optString("color"))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAssignees(array: JSONArray?): List<String> {
|
||||||
|
if (array == null) return emptyList()
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val a = array.optJSONObject(i) ?: continue
|
||||||
|
val p = a.optJSONObject("participant")
|
||||||
|
val name = p?.optString("displayname")?.takeIf { it.isNotBlank() }
|
||||||
|
?: p?.optString("uid")?.takeIf { it.isNotBlank() }
|
||||||
|
?: a.optString("displayname")
|
||||||
|
if (name.isNotBlank()) out += name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Запись ---
|
||||||
|
|
||||||
|
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
|
||||||
|
val body = JSONObject().put("title", title).put("type", "plain").put("order", 999).put("stackId", stackId)
|
||||||
|
val json = sendJson(session, "POST", "${apiBase(session)}/cards", body) as? JSONObject
|
||||||
|
return json?.let { parseCard(it, stackId) }
|
||||||
|
?: DeckCard(id = 0, title = title, done = false, stackId = stackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCard(
|
||||||
|
session: AuthSession,
|
||||||
|
card: DeckCard,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
duedate: String?,
|
||||||
|
) {
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("title", title)
|
||||||
|
.put("type", "plain")
|
||||||
|
.put("owner", session.username)
|
||||||
|
.put("description", description)
|
||||||
|
.put("order", card.order)
|
||||||
|
.put("stackId", card.stackId)
|
||||||
|
.put("duedate", duedate ?: JSONObject.NULL)
|
||||||
|
sendJson(session, "PUT", "${apiBase(session)}/cards/${card.id}", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCardDone(session: AuthSession, cardId: Int, done: Boolean) {
|
||||||
|
val path = if (done) "done" else "undone"
|
||||||
|
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/$path", JSONObject())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveCard(session: AuthSession, cardId: Int, targetStackId: Int, order: Int = 0) {
|
||||||
|
val body = JSONObject().put("stackId", targetStackId).put("order", order)
|
||||||
|
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/reorder", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun archiveCard(session: AuthSession, cardId: Int) {
|
||||||
|
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/archive", JSONObject())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCard(session: AuthSession, cardId: Int) {
|
||||||
|
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assignLabel(session: AuthSession, cardId: Int, labelId: Int) {
|
||||||
|
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/label/$labelId", JSONObject())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeLabel(session: AuthSession, cardId: Int, labelId: Int) {
|
||||||
|
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
|
||||||
|
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
|
||||||
|
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
|
||||||
|
val id = json?.optInt("id", 0) ?: 0
|
||||||
|
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP ---
|
||||||
|
|
||||||
|
private fun getJson(client: OkHttpClient, url: String): Any {
|
||||||
|
// GET — как в прежней рабочей версии (без доп. заголовков); запись отдельно в sendJson.
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (response.code == 401) throw UnauthorizedException()
|
if (!response.isSuccessful || response.body == null) error("Deck API HTTP ${response.code}")
|
||||||
if (!response.isSuccessful || response.body == null) {
|
|
||||||
error("Deck API HTTP ${response.code}")
|
|
||||||
}
|
|
||||||
val body = response.body!!.string().trim()
|
val body = response.body!!.string().trim()
|
||||||
if (body.startsWith("[")) return JSONArray(body)
|
return when {
|
||||||
if (body.startsWith("{")) return JSONObject(body)
|
body.startsWith("[") -> JSONArray(body)
|
||||||
return JSONArray()
|
body.startsWith("{") -> JSONObject(body)
|
||||||
|
else -> JSONArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val jsonMedia = "application/json".toMediaType()
|
||||||
|
|
||||||
|
private fun sendJson(session: AuthSession, method: String, url: String, body: JSONObject?): Any? {
|
||||||
|
val payload = (body?.toString() ?: "{}").toRequestBody(jsonMedia)
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
when (method) {
|
||||||
|
"POST" -> builder.post(payload)
|
||||||
|
"PUT" -> builder.put(payload)
|
||||||
|
"DELETE" -> if (body != null) builder.delete(payload) else builder.delete()
|
||||||
|
}
|
||||||
|
client(session).newCall(builder.build()).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) error("Deck API HTTP ${response.code}")
|
||||||
|
val text = response.body?.string()?.trim().orEmpty()
|
||||||
|
return when {
|
||||||
|
text.startsWith("{") -> JSONObject(text)
|
||||||
|
text.startsWith("[") -> JSONArray(text)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,11 +231,20 @@ data class DeckBoard(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val color: String,
|
val color: String,
|
||||||
|
val canEdit: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeckLabel(
|
||||||
|
val id: Int,
|
||||||
|
val title: String,
|
||||||
|
val color: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DeckStack(
|
data class DeckStack(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
val boardId: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
|
val order: Int,
|
||||||
val cards: List<DeckCard>,
|
val cards: List<DeckCard>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,10 +252,19 @@ data class DeckCard(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
val title: String,
|
val title: String,
|
||||||
val done: Boolean,
|
val done: Boolean,
|
||||||
|
val description: String = "",
|
||||||
|
val duedate: String? = null,
|
||||||
|
val order: Int = 0,
|
||||||
|
val stackId: Int = 0,
|
||||||
|
val labels: List<DeckLabel> = emptyList(),
|
||||||
|
val assignees: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DeckBoardDetail(
|
data class DeckBoardDetail(
|
||||||
val boardId: Int,
|
val boardId: Int,
|
||||||
|
val title: String,
|
||||||
|
val canEdit: Boolean,
|
||||||
|
val labels: List<DeckLabel>,
|
||||||
val stacks: List<DeckStack>,
|
val stacks: List<DeckStack>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||