Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,64 @@
|
|||||||
|
|
||||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||||
|
|
||||||
|
- 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 135
|
||||||
versionName '0.5.114'
|
versionName '0.5.127'
|
||||||
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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package ru.forbion.f7cloud.mobile.ui
|
|||||||
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
|
||||||
|
|
||||||
|
// Иконки-glass шторки — локальные (assets/menu/, из живой темы forbion): офлайн + точные.
|
||||||
|
private fun glass(name: String): String = "file:///android_asset/menu/$name"
|
||||||
|
|
||||||
enum class AppTab(
|
enum class AppTab(
|
||||||
val title: String,
|
val title: String,
|
||||||
val headerIconPath: String,
|
val headerIconPath: String,
|
||||||
@@ -29,16 +32,14 @@ private data class AppMenuEntry(
|
|||||||
val webPath: String? = null,
|
val webPath: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
|
||||||
|
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
|
||||||
private val coreAppMenuEntries = listOf(
|
private val coreAppMenuEntries = listOf(
|
||||||
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
|
|
||||||
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
|
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
|
||||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
|
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
|
||||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
|
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
|
||||||
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
|
|
||||||
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
|
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
|
||||||
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
|
|
||||||
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
|
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
|
||||||
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppMenuExternalSite(
|
data class AppMenuExternalSite(
|
||||||
@@ -58,7 +59,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 = glass(entry.iconPath),
|
||||||
selected = entry.tab == active,
|
selected = entry.tab == active,
|
||||||
externalUrl = entry.webPath?.let { "$base$it" },
|
externalUrl = entry.webPath?.let { "$base$it" },
|
||||||
)
|
)
|
||||||
@@ -71,7 +72,20 @@ fun appMenuItems(
|
|||||||
externalUrl = site.openUrl,
|
externalUrl = site.openUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return core + external
|
// Нативные пункты — тоже glass-иконки живой темы (mobile-*-glass), действия по label в AppScaffold
|
||||||
|
val native = listOf(
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = "Уведомления",
|
||||||
|
iconUrl = glass("mobile-notifications-glass.svg"),
|
||||||
|
selected = false,
|
||||||
|
),
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = "Настройки",
|
||||||
|
iconUrl = glass("mobile-settings-glass.svg"),
|
||||||
|
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) ||
|
||||||
@@ -499,36 +461,22 @@ fun AppScaffold(
|
|||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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 = {
|
onCardsClick = {
|
||||||
menuOpen = false
|
if (activeTab != AppTab.Deck) {
|
||||||
profileOpen = true
|
pushTabHistory(activeTab)
|
||||||
|
activeTab = AppTab.Deck
|
||||||
|
}
|
||||||
},
|
},
|
||||||
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,12 +585,35 @@ 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()
|
||||||
|
when {
|
||||||
|
// Профиль — нативная панель вместо веб-ЛК forbion
|
||||||
|
label.equals("Личный кабинет", ignoreCase = true) -> {
|
||||||
|
menuOpen = false
|
||||||
|
profileOpen = true
|
||||||
|
}
|
||||||
|
// Нативные пункты по дизайну меню
|
||||||
|
label.equals("Уведомления", ignoreCase = true) -> {
|
||||||
|
menuOpen = false
|
||||||
|
hasNotificationBadge = false
|
||||||
|
notificationsOpen = true
|
||||||
|
}
|
||||||
|
label.equals("Настройки", ignoreCase = true) -> {
|
||||||
|
menuOpen = false
|
||||||
|
when (activeTab) {
|
||||||
|
AppTab.Mail -> mailSettingsOpen = true
|
||||||
|
AppTab.Calendar -> calendarSettingsRequest++
|
||||||
|
AppTab.Files -> filesSettingsOpen = true
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!external.isNullOrBlank() -> {
|
||||||
runCatching {
|
runCatching {
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||||
}
|
}
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
} else {
|
}
|
||||||
|
else -> {
|
||||||
appTabFromMenuIndex(index)?.let { tab ->
|
appTabFromMenuIndex(index)?.let { tab ->
|
||||||
if (tab != activeTab) {
|
if (tab != activeTab) {
|
||||||
pushTabHistory(activeTab)
|
pushTabHistory(activeTab)
|
||||||
@@ -651,6 +622,7 @@ fun AppScaffold(
|
|||||||
}
|
}
|
||||||
menuOpen = false
|
menuOpen = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ProfileSheet(
|
ProfileSheet(
|
||||||
@@ -677,254 +649,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,48 +83,180 @@ 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
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(F7Colors.PrimaryLight),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = session.username,
|
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = F7Colors.TextPrimary,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
color = F7Colors.Primary,
|
||||||
Text(
|
|
||||||
text = session.serverUrl,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = F7Colors.TextSecondary,
|
|
||||||
)
|
|
||||||
session.davUserId?.let { davId ->
|
|
||||||
Text(
|
|
||||||
text = "ID: $davId",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = F7Colors.TextSecondary,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
F7PrimaryButton(
|
Column(
|
||||||
text = "Сканировать QR браузера",
|
modifier = Modifier
|
||||||
onClick = {
|
.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()
|
onDismiss()
|
||||||
onScanBrowserQr()
|
onScanBrowserQr()
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.QrCodeScanner,
|
||||||
|
contentDescription = "Сканировать QR браузера",
|
||||||
|
tint = F7Colors.TextSecondary,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
F7PrimaryButton(
|
}
|
||||||
text = "Выйти",
|
}
|
||||||
onClick = {
|
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()
|
onDismiss()
|
||||||
onLogout()
|
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
|
||||||
fun NotificationsSheet(
|
fun NotificationsSheet(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
|
|||||||
@@ -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,44 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36127)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36127)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36127)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M9.46191 34.6143V16.043H39.5381V34.6143C39.5381 36.2224 38.2345 37.5259 36.6264 37.5259H12.3736C10.7655 37.5259 9.46191 36.2224 9.46191 34.6143Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M9.46191 16.0426V12.5093C9.46191 10.9013 10.7655 9.59766 12.3736 9.59766H36.6264C38.2345 9.59766 39.5381 10.9012 39.5381 12.5093V16.0426H9.46191Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M15.1792 7.44922H14.4867C14.0847 7.44922 13.7588 7.77512 13.7588 8.17713V12.092C13.7588 12.4941 14.0847 12.82 14.4867 12.82H15.1792C15.5812 12.82 15.9071 12.4941 15.9071 12.092V8.17713C15.9071 7.77512 15.5812 7.44922 15.1792 7.44922Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M21.6235 7.44922H20.931C20.529 7.44922 20.2031 7.77512 20.2031 8.17713V12.092C20.2031 12.4941 20.529 12.82 20.931 12.82H21.6235C22.0255 12.82 22.3514 12.4941 22.3514 12.092V8.17713C22.3514 7.77512 22.0255 7.44922 21.6235 7.44922Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M28.0688 7.44922H27.3764C26.9743 7.44922 26.6484 7.77512 26.6484 8.17713V12.092C26.6484 12.4941 26.9743 12.82 27.3764 12.82H28.0688C28.4708 12.82 28.7967 12.4941 28.7967 12.092V8.17713C28.7967 7.77512 28.4708 7.44922 28.0688 7.44922Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M34.5132 7.44922H33.8207C33.4187 7.44922 33.0928 7.77512 33.0928 8.17713V12.092C33.0928 12.4941 33.4187 12.82 33.8207 12.82H34.5132C34.9152 12.82 35.2411 12.4941 35.2411 12.092V8.17713C35.2411 7.77512 34.9152 7.44922 34.5132 7.44922Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M24.0094 29.8013V31.043H19.2809V29.8013H20.9992V23.4547C20.9323 23.5551 20.8194 23.668 20.6605 23.7934C20.51 23.9104 20.3386 24.0275 20.1463 24.1446C19.954 24.2533 19.7533 24.3453 19.5443 24.4205C19.3436 24.4958 19.1596 24.5334 18.9924 24.5334V23.2541C19.2181 23.2541 19.4439 23.1955 19.6697 23.0785C19.9038 22.9614 20.1212 22.8276 20.3219 22.6771C20.5226 22.5182 20.6814 22.3761 20.7985 22.2507C20.9239 22.1252 20.9908 22.0583 20.9992 22.05H22.4165V29.8013H24.0094ZM27.7577 31.1433C27.2643 31.1433 26.8128 31.0722 26.4031 30.9301C26.0017 30.7879 25.663 30.5831 25.3871 30.3155C25.1112 30.0479 24.9105 29.7385 24.7851 29.3874L25.5376 28.4843C25.5962 28.7017 25.7174 28.9191 25.9014 29.1365C26.0937 29.3539 26.3403 29.5295 26.6414 29.6633C26.9507 29.7971 27.3103 29.864 27.72 29.864C28.0796 29.864 28.3973 29.8096 28.6733 29.7009C28.9576 29.5839 29.1833 29.4208 29.3506 29.2118C29.5178 29.0027 29.6014 28.756 29.6014 28.4717C29.6014 28.154 29.5011 27.8864 29.3004 27.669C29.0997 27.4516 28.8112 27.2886 28.4349 27.1799C28.0587 27.0712 27.6071 27.0168 27.0804 27.0168H26.6539V25.9131H27.0804C27.7744 25.9131 28.3262 25.8002 28.736 25.5744C29.1541 25.3487 29.3631 25.0142 29.3631 24.571C29.3631 24.2951 29.2878 24.0568 29.1373 23.8561C28.9868 23.6471 28.7903 23.4924 28.5478 23.392C28.3053 23.2833 28.0378 23.229 27.7451 23.229C27.2685 23.229 26.8546 23.3377 26.5034 23.5551C26.1522 23.7725 25.8972 24.0401 25.7383 24.3578L24.8352 23.3669C25.0108 23.091 25.2533 22.8527 25.5627 22.652C25.8721 22.4513 26.2191 22.2966 26.6037 22.1879C26.9967 22.0792 27.3939 22.0249 27.7953 22.0249C28.3806 22.0249 28.9032 22.1294 29.3631 22.3385C29.823 22.5475 30.1825 22.836 30.4418 23.2039C30.701 23.5634 30.8306 23.9732 30.8306 24.4331C30.8306 24.7675 30.7595 25.0685 30.6173 25.3361C30.4836 25.6037 30.2871 25.8295 30.0278 26.0134C29.777 26.189 29.476 26.3186 29.1248 26.4022C29.5011 26.4691 29.8313 26.6113 30.1156 26.8287C30.4083 27.0461 30.6299 27.3137 30.7804 27.6314C30.9393 27.9491 31.0187 28.2962 31.0187 28.6724C31.0187 29.1909 30.8682 29.634 30.5672 30.0019C30.2745 30.3699 29.8815 30.6542 29.3882 30.8548C28.8948 31.0471 28.3513 31.1433 27.7577 31.1433Z" fill="url(#paint2_linear_2888_36127)" fill-opacity="0.91"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36127" x="0" y="0" width="49" height="49" 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_2888_36127"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36127"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36127" in2="effect1_dropShadow_2888_36127" result="effect2_innerShadow_2888_36127"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36127" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36127" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36127" x1="18.0547" y1="16.043" x2="35.1" y2="18.8562" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36139)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36139)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36139)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M13.334 35.4905V11.5096C13.334 9.84638 14.6823 8.49805 16.3456 8.49805H33.6594C35.3227 8.49805 36.671 9.84638 36.671 11.5096V35.4905C36.671 37.1541 35.3224 38.5028 33.6587 38.5028H16.3463C14.6827 38.5028 13.334 37.1541 13.334 35.4905Z" fill="white" stroke="black" stroke-width="1.00411"/>
|
||||||
|
<path d="M13.334 36.2802V10.7206C13.334 9.49313 14.3291 8.49805 15.5566 8.49805H17.7791V38.5028H15.5566C14.3291 38.5028 13.334 37.5077 13.334 36.2802Z" fill="white" stroke="black" stroke-width="1.00411"/>
|
||||||
|
<path d="M10.1787 22.5852V23.3016C10.1787 23.7175 10.5159 24.0547 10.9318 24.0547H14.9821C15.398 24.0547 15.7351 23.7175 15.7351 23.3016V22.5852C15.7351 22.1693 15.398 21.8321 14.9821 21.8321H10.9318C10.5159 21.8321 10.1787 22.1693 10.1787 22.5852Z" fill="white" stroke="black" stroke-width="1.00411"/>
|
||||||
|
<path d="M10.1787 29.2532V29.9696C10.1787 30.3855 10.5159 30.7227 10.9318 30.7227H14.9821C15.398 30.7227 15.7351 30.3855 15.7351 29.9696V29.2532C15.7351 28.8372 15.398 28.5001 14.9821 28.5001H10.9318C10.5159 28.5001 10.1787 28.8372 10.1787 29.2532Z" fill="white" stroke="black" stroke-width="1.00411"/>
|
||||||
|
<path d="M10 15.9192V16.6356C10 17.0515 10.3372 17.3887 10.7531 17.3887H14.8034C15.2193 17.3887 15.5564 17.0515 15.5564 16.6356V15.9192C15.5564 15.5033 15.2193 15.1661 14.8034 15.1661H10.7531C10.3372 15.1661 10 15.5033 10 15.9192Z" fill="white" stroke="black" stroke-width="1.00411"/>
|
||||||
|
<circle cx="27.2246" cy="21.21" r="2.29203" fill="url(#paint2_linear_2888_36139)" fill-opacity="0.91"/>
|
||||||
|
<path d="M27.2247 24.2676C24.6649 24.2676 23.1772 26.2776 22.7615 27.4917C22.6522 27.8111 22.9143 28.0876 23.2518 28.0876H31.1975C31.5351 28.0876 31.7972 27.8111 31.6879 27.4917C31.2722 26.2776 29.7844 24.2676 27.2247 24.2676Z" fill="url(#paint3_linear_2888_36139)" fill-opacity="0.91"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36139" x="0" y="0" width="49" height="49" 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_2888_36139"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36139"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36139" in2="effect1_dropShadow_2888_36139" result="effect2_innerShadow_2888_36139"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36139" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36139" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36139" x1="24.9326" y1="18.918" x2="30.3325" y2="20.2891" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2888_36139" x1="22.6406" y1="24.2676" x2="31.0235" y2="29.3761" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36152)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36152)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36152)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M10.0049 34.4152V12.5841C10.0049 10.9153 11.3577 9.5625 13.0265 9.5625H35.9733C37.6421 9.5625 38.9949 10.9153 38.9949 12.5841V34.4152C38.9949 36.0844 37.6417 37.4375 35.9725 37.4375H13.0272C11.358 37.4375 10.0049 36.0844 10.0049 34.4152Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M12.792 15.1387H18.4224" stroke="url(#paint2_linear_2888_36152)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<path d="M30.6318 15.1387H36.2623" stroke="url(#paint3_linear_2888_36152)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<path d="M21.7119 15.1387H27.3423" stroke="url(#paint4_linear_2888_36152)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<rect x="12.7344" y="31.3613" width="13.495" height="5.69" rx="0.614981" transform="rotate(-90 12.7344 31.3613)" fill="white" stroke="black"/>
|
||||||
|
<rect x="30.5742" y="31.3613" width="13.495" height="5.69" rx="0.614981" transform="rotate(-90 30.5742 31.3613)" fill="white" stroke="black"/>
|
||||||
|
<rect x="21.6543" y="23.5586" width="5.69" height="5.69" rx="0.614981" transform="rotate(-90 21.6543 23.5586)" fill="white" stroke="black"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36152" x="0" y="0" width="49" height="49" 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_2888_36152"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36152"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36152" in2="effect1_dropShadow_2888_36152" result="effect2_innerShadow_2888_36152"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36152" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36152" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36152" x1="12.792" y1="15.1387" x2="15.1114" y2="18.4546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2888_36152" x1="30.6318" y1="15.1387" x2="32.9512" y2="18.4546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2888_36152" x1="21.7119" y1="15.1387" x2="24.0313" y2="18.4546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 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_591)">
|
||||||
|
<circle cx="17.5" cy="17.5" r="17.5" fill="#e5efe8"/>
|
||||||
|
<path d="M6.125 25.6282V8.49625C6.125 7.18664 7.18664 6.125 8.49625 6.125H26.5037C27.8134 6.125 28.875 7.18664 28.875 8.49625V25.6282C28.875 26.9381 27.8131 28 26.5032 28H8.49683C7.18691 28 6.125 26.9381 6.125 25.6282Z" fill="#F5F5F5" stroke="black" stroke-width="0.790611"/>
|
||||||
|
<path d="M8.3125 10.5L12.6875 10.5" stroke="#70B62B" stroke-width="0.7875" stroke-linecap="round"/>
|
||||||
|
<path d="M22.3125 10.5L26.7537 10.5" stroke="#70B62B" stroke-width="0.7875" stroke-linecap="round"/>
|
||||||
|
<path d="M15.3125 10.5L19.8459 10.5" stroke="#70B62B" stroke-width="0.7875" stroke-linecap="round"/>
|
||||||
|
<rect x="8.26875" y="23.2313" width="10.5875" height="4.4625" rx="0.48125" transform="rotate(-90 8.26875 23.2313)" stroke="black" stroke-width="0.7875"/>
|
||||||
|
<rect x="22.2687" y="23.2313" width="10.5875" height="4.4625" rx="0.48125" transform="rotate(-90 22.2687 23.2313)" stroke="black" stroke-width="0.7875"/>
|
||||||
|
<rect x="15.2687" y="17.1063" width="4.4625" height="4.4625" rx="0.48125" transform="rotate(-90 15.2687 17.1063)" stroke="black" stroke-width="0.7875"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_205_591">
|
||||||
|
<rect width="35" height="35" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36114)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36114)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36114)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M38.5625 20.1419V27.3323C38.5625 28.9311 37.2664 30.2272 35.6677 30.2272H12.2519C10.6531 30.2272 9.35707 28.9311 9.35707 27.3323V16.8968C9.35707 15.298 10.6531 14.002 12.2519 14.002H24.3832C25.151 14.002 25.8873 14.3069 26.4302 14.8498L27.9795 16.3991C28.5224 16.942 29.2587 17.247 30.0264 17.247H35.6676C37.2664 17.247 38.5625 18.5431 38.5625 20.1419Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M13.6836 8.01172V29.7271C13.6836 30.0032 13.9075 30.2271 14.1836 30.2271H34.8172C35.0934 30.2271 35.3172 30.0032 35.3172 29.727V8.01172C35.3172 7.73558 35.0934 7.51172 34.8172 7.51172H14.1836C13.9075 7.51172 13.6836 7.73558 13.6836 8.01172Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M12.6016 10.1758V32.9728C12.6016 33.2489 12.8254 33.4728 13.1016 33.4728H32.6535C32.9297 33.4728 33.1535 33.2489 33.1535 32.9728V10.1758C33.1535 9.89964 32.9297 9.67578 32.6535 9.67578H13.1016C12.8254 9.67578 12.6016 9.89964 12.6016 10.1758Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M9.35645 25.552V34.9059C9.35645 36.5046 10.6525 37.8007 12.2513 37.8007H36.7487C38.3475 37.8007 39.6436 36.5046 39.6436 34.9059V22.307C39.6436 20.7082 38.3475 19.4121 36.7487 19.4121H22.0977C21.3387 19.4121 20.61 19.7102 20.0686 20.2423L18.4561 21.827C17.9147 22.359 17.1861 22.6572 16.427 22.6572H12.2513C10.6525 22.6572 9.35645 23.9532 9.35645 25.552Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M14.7656 12.9219H30.9909" stroke="url(#paint2_linear_2888_36114)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<path d="M14.7656 15.084H30.9909" stroke="url(#paint3_linear_2888_36114)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<path d="M14.7656 17.248H30.9909" stroke="url(#paint4_linear_2888_36114)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36114" x="0" y="0" width="49" height="49" 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_2888_36114"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36114"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36114" in2="effect1_dropShadow_2888_36114" result="effect2_innerShadow_2888_36114"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36114" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36114" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36114" x1="14.7656" y1="12.9219" x2="15.8976" y2="17.5854" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2888_36114" x1="14.7656" y1="15.084" x2="15.8976" y2="19.7475" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2888_36114" x1="14.7656" y1="17.248" x2="15.8976" y2="21.9116" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36103)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36103)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36103)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M8.42773 13.3065V34.6143C8.42773 35.2994 8.83838 35.9178 9.46984 36.1836C9.67894 36.2716 9.90352 36.317 10.1304 36.317H23.3758H31.3029H38.9154C39.1238 36.317 39.331 36.285 39.5298 36.2223L39.6153 36.1953C40.4645 35.9272 41.0418 35.1395 41.0418 34.2491V13.3065C41.0418 12.1794 40.1281 11.2656 39.0009 11.2656H24.7348H10.4686C9.34148 11.2656 8.42773 12.1794 8.42773 13.3065Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M40.5785 32.6801L26.9358 18.7502C25.7321 17.5211 23.7523 17.5249 22.5533 18.7586L9.28202 32.414C8.73422 32.9777 8.42777 33.7327 8.42777 34.5187V35.0417C8.42777 35.4792 8.65206 35.886 9.02193 36.1196C9.22561 36.2482 9.46157 36.3164 9.70245 36.3164L11.1456 36.3164H16.486L31.7755 36.3164H39.2299C41.0832 36.3164 41.5051 33.9059 40.5785 32.6801Z" stroke="black"/>
|
||||||
|
<path d="M9.146 14.507L24.064 27.5691C24.4505 27.9076 25.0284 27.9059 25.413 27.5651L40.5352 14.1648C40.8643 13.8732 41.0527 13.4545 41.0527 13.0148C41.0527 12.6817 40.9445 12.3577 40.7444 12.0915L40.5825 11.8761C40.299 11.4989 39.9081 11.216 39.4612 11.0646L38.8141 10.8454C38.4978 10.7383 38.166 10.6836 37.8319 10.6836H32.9989H25.3423H21.2663H17.1904H10.2031C8.47223 10.6836 8.04652 12.7971 8.75386 14.0571C8.85195 14.2318 8.99525 14.375 9.146 14.507Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M24.5492 15.8516C24.2716 15.8516 24.0158 15.9584 23.7809 16.1738C23.5461 16.3891 23.3683 16.6953 23.2492 17.0923C23.1309 17.4893 23.0706 17.8417 23.0706 18.1487C23.0706 18.5584 23.1536 18.8612 23.3188 19.0546C23.4849 19.2481 23.6904 19.3457 23.9353 19.3457L24.1282 20.1784C23.9839 20.2146 23.8346 20.2322 23.6795 20.2322C23.1444 20.2322 22.7007 20.0472 22.3476 19.6771C22.2377 19.5618 22.1446 19.434 22.0691 19.2944C21.9022 18.9874 21.8184 18.6231 21.8184 18.2009C21.8184 17.4414 22.0314 16.7483 22.4566 16.1225C22.9724 15.3595 23.6325 14.9785 24.4385 14.9785C24.5006 14.9785 24.5609 14.981 24.6188 14.9869L24.5492 15.8508V15.8516Z" fill="url(#paint2_linear_2888_36103)" fill-opacity="0.91"/>
|
||||||
|
<path d="M27.3354 14.694C26.7617 14.1591 25.9607 13.8916 24.9333 13.8916C24.0602 13.8916 23.3205 14.0708 22.7132 14.4274C22.106 14.784 21.6481 15.2862 21.3403 15.9347C21.0325 16.5824 20.879 17.257 20.879 17.9576C20.879 18.5111 20.9922 19.025 21.2186 19.4986C21.2706 19.6079 21.3294 19.7148 21.3931 19.8199C21.7361 20.3793 22.2184 20.7864 22.8382 21.0429C23.458 21.2995 24.1676 21.4281 24.9668 21.4281C25.7661 21.4281 26.3994 21.3205 26.9529 21.106C27.5073 20.8915 27.9535 20.5685 28.2923 20.1395H29.3214C28.9994 20.7956 28.5054 21.3087 27.8377 21.6813C27.0737 22.1078 26.1427 22.3214 25.044 22.3214C23.9453 22.3214 23.0621 22.1414 22.2897 21.7814C21.5164 21.4206 20.9419 20.8898 20.5645 20.1866C20.1879 19.4826 20 18.7172 20 17.8903C20 16.9819 20.2139 16.1366 20.6425 15.3526C21.071 14.5696 21.6581 13.9808 22.4021 13.588C23.146 13.1968 23.9965 13 24.9534 13C25.7653 13 26.4866 13.159 27.119 13.4752C27.7505 13.7915 28.2328 14.2424 28.5658 14.8253C28.8996 15.4082 29.0665 16.0457 29.0665 16.7372C29.0665 17.5606 28.8132 18.3059 28.3074 18.9712C27.6725 19.8115 26.859 20.2312 25.8668 20.2312C25.6001 20.2312 25.3979 20.185 25.2621 20.0907C25.127 19.9974 25.0365 19.8603 24.992 19.6794C24.7303 19.9326 24.441 20.0983 24.1256 20.1774L23.9327 19.3447C24.1164 19.3447 24.2883 19.2992 24.4494 19.2084C24.5718 19.1445 24.6926 19.0427 24.8117 18.9031C24.9828 18.7062 25.1296 18.4186 25.2545 18.0401C25.3787 17.6624 25.4407 17.3108 25.4407 16.9836C25.4407 16.6194 25.356 16.3385 25.1874 16.1433C25.018 15.9473 24.805 15.8498 24.5475 15.8498L24.6171 14.9859C25.0994 15.028 25.4634 15.245 25.7074 15.637L25.8282 15.0986H27.1039L26.3742 18.5733C26.3297 18.7937 26.3063 18.9359 26.3063 18.9998C26.3063 19.0805 26.3247 19.1411 26.3625 19.1815C26.4002 19.2219 26.4438 19.242 26.495 19.242C26.6493 19.242 26.8497 19.1487 27.0938 18.9611C27.4226 18.7155 27.6885 18.3866 27.8914 17.9728C28.0944 17.5589 28.1959 17.1308 28.1959 16.6884C28.1959 15.9431 27.9434 15.3131 27.4402 14.7958C27.4067 14.7613 27.3714 14.7277 27.3354 14.694Z" fill="url(#paint3_linear_2888_36103)" fill-opacity="0.91"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36103" x="0" y="0" width="49" height="49" 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_2888_36103"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36103"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36103" in2="effect1_dropShadow_2888_36103" result="effect2_innerShadow_2888_36103"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36103" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36103" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36103" x1="21.8184" y1="14.9785" x2="25.2667" y2="15.4453" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2888_36103" x1="20" y1="13" x2="30.9804" y2="15.7881" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="17.5" cy="17.5" r="17.5" fill="#e5efe8"/>
|
||||||
|
<path d="M5 10.0405V26.6132C5 27.1461 5.31939 27.627 5.81053 27.8338C5.97316 27.9022 6.14783 27.9375 6.32429 27.9375H16.6263H22.7918H28.7126C28.8747 27.9375 29.0359 27.9127 29.1905 27.8639L29.257 27.8429C29.9174 27.6344 30.3665 27.0217 30.3665 26.3291V10.0405C30.3665 9.16381 29.6558 8.45312 28.7791 8.45312H17.6832H6.58736C5.71069 8.45312 5 9.16381 5 10.0405Z" fill="#F5F5F5" stroke="black" stroke-width="0.793683"/>
|
||||||
|
<path d="M30.0062 25.1093L19.3952 14.2749C18.4589 13.3189 16.9191 13.3219 15.9866 14.2814L5.66445 24.9023C5.23838 25.3407 5.00003 25.928 5.00003 26.5393V26.9461C5.00003 27.2863 5.17448 27.6028 5.46215 27.7844C5.62057 27.8844 5.80409 27.9375 5.99144 27.9375H7.1139H11.2675L23.1594 27.9375L28.9572 27.9375C30.3987 27.9375 30.7269 26.0626 30.0062 25.1093Z" stroke="black" stroke-width="0.793683"/>
|
||||||
|
<path d="M5.55865 10.9737L17.1615 21.1332C17.4622 21.3965 17.9117 21.3951 18.2107 21.1301L29.9725 10.7076C30.2285 10.4808 30.375 10.1551 30.375 9.81312C30.375 9.55409 30.2908 9.30207 30.1352 9.09501L30.0093 8.92751C29.7887 8.6341 29.4848 8.41412 29.1371 8.29636L28.6339 8.12588C28.3878 8.04252 28.1297 8 27.8699 8H24.1109H18.1557H14.9856H11.8154H6.38085C5.0346 8 4.7035 9.64383 5.25366 10.6238C5.32995 10.7597 5.4414 10.8711 5.55865 10.9737Z" fill="#F5F5F5" stroke="black" stroke-width="0.793683"/>
|
||||||
|
<path d="M17.5381 12.2162C17.3221 12.2162 17.1232 12.2993 16.9405 12.4667C16.7579 12.6342 16.6196 12.8724 16.5269 13.1811C16.435 13.4899 16.388 13.7641 16.388 14.0028C16.388 14.3214 16.4526 14.557 16.5811 14.7074C16.7102 14.8579 16.8701 14.9338 17.0605 14.9338L17.2106 15.5815C17.0984 15.6096 16.9823 15.6233 16.8616 15.6233C16.4454 15.6233 16.1003 15.4794 15.8257 15.1915C15.7402 15.1019 15.6678 15.0025 15.6091 14.8939C15.4793 14.6551 15.4141 14.3718 15.4141 14.0434C15.4141 13.4526 15.5798 12.9136 15.9105 12.4268C16.3117 11.8335 16.8251 11.5371 17.4519 11.5371C17.5002 11.5371 17.5472 11.5391 17.5922 11.5437L17.5381 12.2155V12.2162Z" fill="#70B62B"/>
|
||||||
|
<path d="M19.7053 11.3176C19.2591 10.9015 18.6361 10.6935 17.837 10.6935C17.1579 10.6935 16.5826 10.8328 16.1103 11.1102C15.638 11.3876 15.2818 11.7782 15.0424 12.2826C14.803 12.7863 14.6836 13.311 14.6836 13.8559C14.6836 14.2864 14.7717 14.6861 14.9478 15.0545C14.9883 15.1395 15.0339 15.2226 15.0835 15.3044C15.3503 15.7394 15.7254 16.0561 16.2075 16.2556C16.6896 16.4551 17.2414 16.5552 17.8631 16.5552C18.4848 16.5552 18.9773 16.4715 19.4078 16.3047C19.839 16.1378 20.186 15.8866 20.4496 15.553H21.25C20.9995 16.0633 20.6153 16.4623 20.096 16.7521C19.5018 17.0838 18.7777 17.25 17.9231 17.25C17.0686 17.25 16.3817 17.11 15.7809 16.83C15.1794 16.5493 14.7326 16.1365 14.439 15.5896C14.1461 15.042 14 14.4467 14 13.8036C14 13.097 14.1663 12.4396 14.4997 11.8298C14.833 11.2208 15.2897 10.7628 15.8683 10.4573C16.4469 10.1531 17.1084 10 17.8527 10C18.4841 10 19.0451 10.1236 19.537 10.3696C20.0282 10.6156 20.4033 10.9663 20.6623 11.4196C20.9219 11.873 21.0517 12.3689 21.0517 12.9067C21.0517 13.5471 20.8547 14.1268 20.4613 14.6443C19.9675 15.2978 19.3348 15.6243 18.5631 15.6243C18.3556 15.6243 18.1984 15.5883 18.0927 15.515C17.9877 15.4424 17.9172 15.3358 17.8827 15.1951C17.6791 15.392 17.4541 15.5209 17.2088 15.5824L17.0588 14.9347C17.2016 14.9347 17.3354 14.8994 17.4606 14.8288C17.5559 14.779 17.6498 14.6999 17.7424 14.5913C17.8755 14.4382 17.9897 14.2144 18.0869 13.9201C18.1834 13.6263 18.2317 13.3528 18.2317 13.0984C18.2317 12.8151 18.1658 12.5966 18.0347 12.4448C17.9029 12.2924 17.7372 12.2165 17.5369 12.2165L17.5911 11.5446C17.9662 11.5773 18.2493 11.7461 18.4391 12.051L18.533 11.6323H19.5252L18.9577 14.3348C18.9231 14.5062 18.9049 14.6168 18.9049 14.6665C18.9049 14.7293 18.9192 14.7764 18.9486 14.8078C18.9779 14.8392 19.0119 14.8549 19.0516 14.8549C19.1717 14.8549 19.3276 14.7823 19.5174 14.6364C19.7731 14.4454 19.9799 14.1896 20.1378 13.8677C20.2956 13.5458 20.3746 13.2128 20.3746 12.8687C20.3746 12.2891 20.1782 11.7991 19.7868 11.3967C19.7607 11.3699 19.7333 11.3438 19.7053 11.3176Z" fill="#70B62B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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,31 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_mobile_lk)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="#FBFBFB"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint0_linear_mobile_lk)" stroke-opacity="0.2"/>
|
||||||
|
</g>
|
||||||
|
<path d="M21.6484 30.5127L21.4043 30.3672C20.5844 29.8802 19.8801 29.3418 19.2324 28.9043L19.0439 28.7695C18.119 28.0734 17.4972 27.0443 17.3047 25.8936L16.9492 23.7686L16.8799 23.3516H16.4561C15.6199 23.3515 14.9443 22.676 14.9443 21.8398C14.9443 21.2184 15.3169 20.6859 15.8516 20.4541L16.3086 20.2568L16.1133 19.7988C15.5068 18.3768 15.0616 16.8689 14.7939 15.3223V15.3213C14.633 14.3931 14.554 13.4916 14.7578 12.6826L14.8018 12.5215C15.0627 11.6631 15.7059 11.0626 16.3135 11.043L16.4043 11.0488L16.6992 11.0693L16.8594 10.8193C17.3654 10.0292 18.1702 9.48976 19.0498 9.1709C19.9382 8.84842 20.8853 8.73496 21.8906 8.61914L21.8896 8.61816C22.2806 8.57336 22.6678 8.53 23.0557 8.49121L23.0547 8.49023C24.8442 8.31807 26.7407 8.28354 28.501 8.77344C30.2556 9.26222 31.9082 10.3473 32.7705 12.0332C33.4592 13.378 33.6123 15.0327 33.4307 16.6416C33.3107 17.7074 33.054 18.7541 32.7461 19.8057L32.6143 20.2578L33.0576 20.417C33.6407 20.6259 34.0566 21.1833 34.0566 21.8398C34.0566 22.6762 33.3819 23.3516 32.5449 23.3516H32.1211L32.0518 23.7686L31.6963 25.8936C31.491 27.1211 30.7974 28.2101 29.7686 28.9043C29.119 29.3424 28.4178 29.88 27.5977 30.3672L27.3525 30.5127V32.2617L27.7021 32.3721L34.3613 34.4746C36.836 35.2559 38.7321 37.1384 39.5879 39.4785C39.4957 39.5654 39.4051 39.6539 39.3115 39.7393L38.7871 40.2021C38.0881 40.801 37.3502 41.3559 36.5791 41.8633C33.1124 44.1452 28.9623 45.4726 24.501 45.4727C20.3184 45.4727 16.4089 44.3062 13.0801 42.2803L12.4219 41.8633C11.6508 41.3559 10.9129 40.801 10.2139 40.2021L9.68945 39.7393C9.59565 39.6538 9.50443 39.5656 9.41211 39.4785C10.2674 37.1379 12.1653 35.2555 14.6396 34.4746L21.2998 32.3721L21.6484 32.2617V30.5127Z" fill="#F5F5F5" stroke="#151515"/>
|
||||||
|
<path d="M25.45 32.4766H23.6647C23.5097 32.4766 23.4011 32.6295 23.4523 32.7758L23.8998 34.0557C23.9314 34.1459 24.0165 34.2064 24.1122 34.2064H25.0025C25.0981 34.2064 25.1833 34.1459 25.2149 34.0557L25.6624 32.7758C25.7135 32.6295 25.6049 32.4766 25.45 32.4766Z" fill="#70B62B"/>
|
||||||
|
<path d="M22.4034 42.3473L23.921 34.2577C23.9409 34.1513 24.0338 34.0742 24.1421 34.0742H24.9757C25.0851 34.0742 25.1787 34.153 25.1974 34.2608L26.5986 42.3528C26.6101 42.4189 26.5914 42.4868 26.5476 42.5377L24.7261 44.6592C24.638 44.7618 24.4799 44.7641 24.3889 44.664L22.4581 42.5401C22.4107 42.4879 22.3905 42.4165 22.4034 42.3473Z" fill="#70B62B"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_mobile_lk" x="0" y="0" width="49" height="49" 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"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_mobile_lk" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_mobile_notif)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="#FBFBFB"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint0_linear_mobile_notif)" stroke-opacity="0.2"/>
|
||||||
|
</g>
|
||||||
|
<path d="M25.45 32.4766H23.6647C23.5097 32.4766 23.4011 32.6295 23.4523 32.7758L23.8998 34.0557C23.9314 34.1459 24.0165 34.2064 24.1122 34.2064H25.0025C25.0981 34.2064 25.1833 34.1459 25.2149 34.0557L25.6624 32.7758C25.7135 32.6295 25.6049 32.4766 25.45 32.4766Z" fill="#70B62B"/>
|
||||||
|
<g clip-path="url(#clip0_mobile_notif)">
|
||||||
|
<path d="M26.873 34.1191C26.8587 35.4359 26.5062 36.604 25.9668 37.4336C25.413 38.2852 24.7067 38.7265 24 38.7266C23.2932 38.7266 22.586 38.2854 22.0322 37.4336C21.4929 36.604 21.1403 35.4358 21.126 34.1191C21.7482 34.1398 22.4045 34.1546 23.0967 34.1611L24 34.165C25.0307 34.165 25.9866 34.1486 26.873 34.1191Z" fill="#F5F5F5" stroke="#151515"/>
|
||||||
|
<circle cx="23.9108" cy="10.1384" r="1.91181" fill="white" stroke="#151515"/>
|
||||||
|
<path d="M23.918 11.918C24.6991 11.9182 25.1174 11.9077 25.8135 12.0244C26.3882 12.1208 26.7767 12.283 27.2783 12.4951V12.4941C30.3861 13.8088 32.5654 16.8863 32.5654 20.4707V26.2158C32.5654 27.4878 33.1606 28.6857 34.1719 29.4561C34.5152 29.7176 34.9215 30.011 35.3926 30.3262C35.5803 30.4517 35.6934 30.6634 35.6934 30.8906V33.3008C35.6932 33.5439 35.5641 33.7652 35.3604 33.8857L35.2686 33.9307C34.2146 34.3548 31.3656 34.9795 23.9092 34.9795C16.4527 34.9795 13.6037 34.3548 12.5498 33.9307C12.2938 33.8277 12.1251 33.5786 12.125 33.3008V30.8906C12.125 30.6633 12.2381 30.4517 12.4258 30.3262C12.8969 30.011 13.3031 29.7176 13.6465 29.4561C14.6577 28.6857 15.2529 27.4878 15.2529 26.2158V20.5225C15.2531 16.3239 17.8426 12.5461 21.9854 12.0283C22.75 11.9328 23.1522 11.9178 23.918 11.918Z" fill="#F5F5F5" stroke="#151515"/>
|
||||||
|
<path d="M11.0015 18.9519C11.0717 18.9699 11.1419 18.978 11.2113 18.978C11.5917 18.978 11.9382 18.7213 12.0358 18.3356C12.0464 18.2936 13.1533 14.0906 17.2979 11.8968C17.7134 11.6768 17.8722 11.1618 17.6522 10.7463C17.4322 10.3308 16.9171 10.172 16.5016 10.392C15.3903 10.9801 14.3773 11.7409 13.4908 12.6527C12.7843 13.3792 12.1566 14.2028 11.6252 15.0999C10.7167 16.6341 10.3987 17.8655 10.3857 17.9173C10.2702 18.3732 10.5461 18.8364 11.0015 18.9519Z" fill="#70B62B"/>
|
||||||
|
<path d="M30.425 11.8969C31.3683 12.3961 32.2299 13.041 32.9862 13.8132C33.596 14.4365 34.1405 15.1446 34.6033 15.9177C35.3976 17.2442 35.6829 18.3176 35.6878 18.3376C35.7862 18.7221 36.1323 18.978 36.5119 18.978C36.5813 18.978 36.6515 18.9695 36.7217 18.9519C37.1776 18.8364 37.4531 18.3736 37.3376 17.9176C37.3245 17.8658 37.0066 16.6344 36.098 15.1001C35.5666 14.2026 34.9388 13.3793 34.2323 12.6528C33.3458 11.741 32.3327 10.9802 31.2213 10.392C30.8058 10.172 30.2907 10.3308 30.0707 10.7463C29.8507 11.1618 30.0095 11.6769 30.425 11.8969Z" fill="#70B62B"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_mobile_notif" x="0" y="0" width="49" height="49" 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"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_mobile_notif" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_mobile_notif">
|
||||||
|
<rect width="29.25" height="31.5" fill="white" transform="translate(9.375 7.72656)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_mobile_settings)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="#FBFBFB"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint0_linear_mobile_settings)" stroke-opacity="0.2"/>
|
||||||
|
</g>
|
||||||
|
<path d="M17.2747 15.3916C17.6719 15.5314 18.0947 15.5836 18.514 15.5447C18.9333 15.5057 19.3392 15.3765 19.7039 15.1659C20.0685 14.9552 20.3833 14.6682 20.6266 14.3245C20.8699 13.9807 21.0359 13.5884 21.1133 13.1745L21.5804 10.6641C23.5009 10.2266 25.4952 10.2266 27.4157 10.6641L27.8857 13.1745C27.963 13.5884 28.1291 13.9807 28.3724 14.3245C28.6156 14.6682 28.9304 14.9552 29.2951 15.1659C29.6597 15.3765 30.0657 15.5057 30.485 15.5447C30.9043 15.5836 31.3271 15.5314 31.7243 15.3916L34.1311 14.545C35.4708 15.9882 36.469 17.7141 37.0517 19.5951L35.1105 21.259C34.7908 21.533 34.5341 21.873 34.3582 22.2556C34.1822 22.6381 34.0911 23.0542 34.0911 23.4753C34.0911 23.8964 34.1822 24.3125 34.3582 24.6951C34.5341 25.0777 34.7908 25.4176 35.1105 25.6917L37.0517 27.3541C36.469 29.2351 35.4708 30.961 34.1311 32.4042L31.7228 31.5576C31.3256 31.4178 30.9028 31.3656 30.4835 31.4046C30.0642 31.4435 29.6583 31.5727 29.2936 31.7834C28.929 31.994 28.6142 32.281 28.3709 32.6247C28.1276 32.9685 27.9616 33.3608 27.8842 33.7747L27.4186 36.2852C25.4981 36.7227 23.5038 36.7227 21.5833 36.2852L21.1133 33.7747C21.0359 33.3608 20.8699 32.9685 20.6266 32.6247C20.3833 32.281 20.0685 31.994 19.7039 31.7834C19.3392 31.5727 18.9333 31.4435 18.514 31.4046C18.0947 31.3656 17.6719 31.4178 17.2747 31.5576L14.8678 32.4042C13.5281 30.961 12.53 29.2351 11.9473 27.3541L13.8885 25.6902C14.2079 25.4162 14.4644 25.0763 14.6402 24.6939C14.816 24.3114 14.907 23.8955 14.907 23.4746C14.907 23.0537 14.816 22.6378 14.6402 22.2554C14.4644 21.8729 14.2079 21.533 13.8885 21.259L11.9473 19.5951C12.5295 17.7142 13.5272 15.9883 14.8664 14.545L17.2747 15.3916ZM24.4995 19.0959C25.6608 19.0959 26.7745 19.5573 27.5957 20.3784C28.4168 21.1996 28.8782 22.3133 28.8782 23.4746C28.8782 24.6359 28.4168 25.7496 27.5957 26.5708C26.7745 27.392 25.6608 27.8533 24.4995 27.8533C23.3382 27.8533 22.2244 27.392 21.4033 26.5708C20.5821 25.7496 20.1208 24.6359 20.1208 23.4746C20.1208 22.3133 20.5821 21.1996 21.4033 20.3784C22.2244 19.5573 23.3382 19.0959 24.4995 19.0959Z" stroke="#151515" stroke-width="1.04493" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_mobile_settings" x="0" y="0" width="49" height="49" 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"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_mobile_settings" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,51 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36178)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36178)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36178)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M10.5596 34.5319V12.4645C10.5596 10.8596 11.8606 9.55859 13.4655 9.55859H35.5336C37.1385 9.55859 38.4396 10.8596 38.4396 12.4645V34.5319C38.4396 36.1372 37.1382 37.4386 35.5329 37.4386H13.4662C11.8609 37.4386 10.5596 36.1372 10.5596 34.5319Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M32.3645 20.0973L34.4368 22.0599M26.6445 27.7874L29.2714 27.6498L34.7878 21.8678C34.9257 21.7232 35.0352 21.5515 35.1099 21.3625C35.1846 21.1736 35.223 20.971 35.223 20.7665C35.223 20.562 35.1846 20.3594 35.1099 20.1705C35.0352 19.9815 34.9257 19.8098 34.7878 19.6652C34.6498 19.5205 34.486 19.4058 34.3057 19.3275C34.1254 19.2493 33.9322 19.209 33.737 19.209C33.5419 19.209 33.3487 19.2493 33.1684 19.3275C32.9881 19.4058 32.8243 19.5205 32.6863 19.6652L27.1699 25.4471L26.6445 27.7874Z" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.7773 22.4258H26.645" stroke="url(#paint2_linear_2888_36178)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<path d="M13.7773 26.7148H24.5004" stroke="url(#paint3_linear_2888_36178)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<path d="M13.7773 31.0039H26.645" stroke="url(#paint4_linear_2888_36178)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<path d="M10.5596 15.9924V12.4653C10.5596 10.86 11.8609 9.55859 13.4662 9.55859H35.5329C37.1382 9.55859 38.4396 10.86 38.4396 12.4653V15.9924H10.5596Z" fill="white" stroke="black" stroke-width="0.968889"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36178" x="0" y="0" width="49" height="49" 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_2888_36178"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36178"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36178" in2="effect1_dropShadow_2888_36178" result="effect2_innerShadow_2888_36178"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36178" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36178" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36178" x1="13.7773" y1="22.4258" x2="15.1593" y2="26.9411" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2888_36178" x1="13.7773" y1="26.7148" x2="15.3755" y2="31.0662" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2888_36178" x1="13.7773" y1="31.0039" x2="15.1593" y2="35.5192" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="4" cy="4" r="4" fill="#B62B2B"/>
|
||||||
|
<path d="M4.05859 1.91016V4.44118" stroke="white" stroke-width="0.86956" stroke-linecap="round"/>
|
||||||
|
<circle cx="4.05833" cy="5.92942" r="0.511452" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 304 B |
@@ -0,0 +1,40 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36093)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36093)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36093)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M22.5421 31.5021C25.4539 31.5021 28.1032 30.3783 30.08 28.5408C32.2554 26.5187 33.6163 23.6322 33.6163 20.4278C33.6163 14.3115 28.6583 9.35352 22.5421 9.35352C16.4258 9.35352 11.4678 14.3115 11.4678 20.4278C11.4678 26.5441 16.4258 31.5021 22.5421 31.5021Z" fill="white" stroke="black" stroke-width="1.11673" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M34.7117 37.4755L28.4082 30.2022L30.6417 27.9688L37.2753 35.5501C37.8919 36.2547 37.7206 37.3456 36.9177 37.8273C36.1945 38.2612 35.2641 38.1128 34.7117 37.4755Z" fill="white" stroke="black" stroke-width="1.11673"/>
|
||||||
|
<path d="M27.85 17.36C27.1173 16.2965 26.247 15.4531 25.2888 14.8782C24.3306 14.3033 23.3034 14.0082 22.2663 14.0098C21.2292 14.0082 20.202 14.3033 19.2438 14.8782C18.2856 15.4531 17.4152 16.2965 16.6826 17.36" stroke="url(#paint2_linear_2888_36093)" stroke-opacity="0.91" stroke-width="1.6751" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36093" x="0" y="0" width="49" height="49" 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_2888_36093"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36093"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36093" in2="effect1_dropShadow_2888_36093" result="effect2_innerShadow_2888_36093"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36093" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36093" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36093" x1="16.6826" y1="14.0098" x2="24.841" y2="20.915" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 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 |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36209)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36209)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36209)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M22.2695 33.4517V32.061C22.2695 31.3434 22.8513 30.7617 23.5688 30.7617H25.4328C26.1504 30.7617 26.7321 31.3434 26.7321 32.061V33.4517C26.7321 34.1693 26.1504 34.751 25.4328 34.751H23.5688C22.8513 34.751 22.2695 34.1693 22.2695 33.4517Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M16.2301 27.8098C18.4045 29.9843 21.2222 31.1235 24.0706 31.2275C27.2051 31.342 30.377 30.2027 32.7699 27.8098C37.3374 23.2424 37.3374 15.8374 32.7699 11.2699C28.2025 6.7025 20.7975 6.7025 16.2301 11.2699C11.6626 15.8374 11.6626 23.2424 16.2301 27.8098Z" fill="white" stroke="black" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.7786 23.2638C21.7571 24.2423 23.025 24.7549 24.3068 24.8017C25.7173 24.8532 27.1446 24.3406 28.2214 23.2638C30.2767 21.2085 30.2767 17.8763 28.2214 15.821C26.1661 13.7657 22.8339 13.7657 20.7786 15.821C18.7233 17.8763 18.7233 21.2085 20.7786 23.2638Z" fill="white" stroke="black" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M23.7287 12.1655C23.9315 12.3683 24.1943 12.4746 24.4599 12.4843C24.7523 12.4949 25.0481 12.3887 25.2713 12.1655C25.6973 11.7395 25.6973 11.0489 25.2713 10.6229C24.8453 10.1969 24.1547 10.1969 23.7287 10.6229C23.3027 11.0489 23.3027 11.7395 23.7287 12.1655Z" fill="url(#paint2_linear_2888_36209)" fill-opacity="0.91"/>
|
||||||
|
<path d="M16.8008 37.0142V35.6235C16.8008 34.9059 17.3825 34.3242 18.1001 34.3242H30.9003C31.6179 34.3242 32.1996 34.9059 32.1996 35.6235V37.0142C32.1996 37.7318 31.6179 38.3135 30.9003 38.3135H18.1001C17.3825 38.3135 16.8008 37.7318 16.8008 37.0142Z" fill="white" stroke="black"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36209" x="0" y="0" width="49" height="49" 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_2888_36209"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36209"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36209" in2="effect1_dropShadow_2888_36209" result="effect2_innerShadow_2888_36209"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36209" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36209" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36209" x1="24.5" y1="9.85156" x2="25.8558" y2="12.1301" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1200_28545)">
|
||||||
|
<circle cx="15" cy="15" r="15" fill="#e5efe8"/>
|
||||||
|
<path d="M13.5283 21.8039V20.8864C13.5283 20.4131 13.9121 20.0293 14.3855 20.0293H15.6151C16.0885 20.0293 16.4722 20.4131 16.4722 20.8864V21.8039C16.4722 22.2772 16.0885 22.661 15.6151 22.661H14.3855C13.9121 22.661 13.5283 22.2772 13.5283 21.8039Z" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<path d="M9.54439 18.0817C10.9789 19.5161 12.8376 20.2677 14.7167 20.3363C16.7846 20.4118 18.877 19.6603 20.4556 18.0817C23.4687 15.0686 23.4687 10.1835 20.4556 7.17045C17.4425 4.15735 12.5575 4.15735 9.54439 7.17045C6.5313 10.1835 6.5313 15.0686 9.54439 18.0817Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.545 15.0817C13.1905 15.7272 14.027 16.0654 14.8725 16.0963C15.803 16.1303 16.7446 15.7921 17.455 15.0817C18.8108 13.7258 18.8108 11.5276 17.455 10.1718C16.0991 8.8159 13.9009 8.8159 12.545 10.1718C11.1892 11.5276 11.1892 13.7258 12.545 15.0817Z" fill="#F5F5F5" stroke="black" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14.3769 7.87512C14.5407 8.03895 14.753 8.12479 14.9676 8.13262C15.2038 8.14125 15.4428 8.05541 15.6231 7.87512C15.9672 7.53099 15.9672 6.97307 15.6231 6.62895C15.279 6.28482 14.721 6.28482 14.3769 6.62895C14.0328 6.97307 14.0328 7.53099 14.3769 7.87512Z" fill="#70B62B"/>
|
||||||
|
<path d="M9.9209 24.1535V23.236C9.9209 22.7627 10.3047 22.3789 10.778 22.3789H19.2222C19.6956 22.3789 20.0794 22.7627 20.0794 23.236V24.1535C20.0794 24.6268 19.6956 25.0106 19.2222 25.0106H10.778C10.3047 25.0106 9.9209 24.6268 9.9209 24.1535Z" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1200_28545">
|
||||||
|
<rect width="30" height="30" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g filter="url(#filter0_di_2888_36164)">
|
||||||
|
<circle cx="24.5" cy="23.5" r="22.5" fill="url(#paint0_linear_2888_36164)" fill-opacity="0.6" shape-rendering="crispEdges"/>
|
||||||
|
<circle cx="24.5" cy="23.5" r="22" stroke="url(#paint1_linear_2888_36164)" stroke-opacity="0.2" shape-rendering="crispEdges"/>
|
||||||
|
</g>
|
||||||
|
<path d="M10.5596 34.5319V12.4645C10.5596 10.8596 11.8606 9.55859 13.4655 9.55859H35.5336C37.1385 9.55859 38.4396 10.8596 38.4396 12.4645V34.5319C38.4396 36.1372 37.1382 37.4386 35.5329 37.4386H13.4662C11.8609 37.4386 10.5596 36.1372 10.5596 34.5319Z" fill="white" stroke="black"/>
|
||||||
|
<path d="M10.5596 17.6009V12.4653C10.5596 10.86 11.8609 9.55859 13.4662 9.55859H35.5329C37.1382 9.55859 38.4396 10.86 38.4396 12.4653V17.6009H10.5596Z" fill="white" stroke="black"/>
|
||||||
|
<circle cx="13.5093" cy="13.5796" r="0.804231" fill="url(#paint2_linear_2888_36164)" fill-opacity="0.91"/>
|
||||||
|
<circle cx="15.8687" cy="13.5796" r="0.804231" fill="url(#paint3_linear_2888_36164)" fill-opacity="0.91"/>
|
||||||
|
<circle cx="18.229" cy="13.5796" r="0.804231" fill="url(#paint4_linear_2888_36164)" fill-opacity="0.91"/>
|
||||||
|
<path d="M21.2832 13.5801H35.9132" stroke="url(#paint5_linear_2888_36164)" stroke-opacity="0.91" stroke-linecap="round"/>
|
||||||
|
<rect x="13.2051" y="20.7832" width="9.72308" height="3.28923" rx="0.572308" stroke="black"/>
|
||||||
|
<rect x="13.2051" y="26.1445" width="15.0846" height="3.28923" rx="0.572308" stroke="black"/>
|
||||||
|
<rect x="18.5664" y="31.5078" width="17.2292" height="3.28923" rx="0.572308" stroke="black"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_2888_36164" x="0" y="0" width="49" height="49" 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_2888_36164"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" 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="1"/>
|
||||||
|
<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_2888_36164"/>
|
||||||
|
<feBlend mode="normal" in="effect2_innerShadow_2888_36164" in2="effect1_dropShadow_2888_36164" result="effect2_innerShadow_2888_36164"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2888_36164" x1="2" y1="1" x2="55.0086" y2="14.4599" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.453125" stop-color="white"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2888_36164" x1="3.40625" y1="3.04546" x2="47" y2="3.04546" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2888_36164" x1="12.7051" y1="12.7754" x2="14.5998" y2="13.2565" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2888_36164" x1="15.0645" y1="12.7754" x2="16.9592" y2="13.2565" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2888_36164" x1="17.4248" y1="12.7754" x2="19.3195" y2="13.2565" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint5_linear_2888_36164" x1="21.2832" y1="13.5801" x2="22.5227" y2="18.1847" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.453125" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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,6 +89,27 @@ fun F7AppMenuSheet(
|
|||||||
}
|
}
|
||||||
val base = serverUrl.trimEnd('/')
|
val base = serverUrl.trimEnd('/')
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(200)),
|
||||||
|
exit = fadeOut(tween(200)),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.18f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Сам лист — выезжает снизу вверх (translateY 100%→0), как в живом forbion:
|
||||||
|
// без скругления верха и без ручки, высота по контенту до почти всего экрана.
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(250)) + slideInVertically(
|
enter = fadeIn(tween(250)) + slideInVertically(
|
||||||
@@ -88,15 +120,16 @@ fun F7AppMenuSheet(
|
|||||||
animationSpec = tween(300),
|
animationSpec = tween(300),
|
||||||
targetOffsetY = { it },
|
targetOffsetY = { it },
|
||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(bottom = BottomBarReserve)
|
.heightIn(max = 640.dp)
|
||||||
.navigationBarsPadding()
|
|
||||||
.background(F7Colors.Background)
|
.background(F7Colors.Background)
|
||||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
.navigationBarsPadding()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = BottomBarReserve)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
F7AppMenuSearchField(
|
F7AppMenuSearchField(
|
||||||
serverUrl = base,
|
serverUrl = base,
|
||||||
@@ -106,32 +139,33 @@ fun F7AppMenuSheet(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 24.dp),
|
||||||
)
|
)
|
||||||
LazyVerticalGrid(
|
// Сетка 3 колонки, gap 8 (живой forbion). Не-ленивая — лист сам в verticalScroll.
|
||||||
columns = GridCells.Fixed(4),
|
Column(
|
||||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
filteredItems.chunked(3).forEach { rowItems ->
|
||||||
items = filteredItems,
|
Row(
|
||||||
key = { index, item -> "${item.label}-$index" },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { index, item ->
|
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||||
|
) {
|
||||||
|
rowItems.forEach { item ->
|
||||||
val originalIndex = items.indexOf(item)
|
val originalIndex = items.indexOf(item)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.weight(1f),
|
||||||
contentAlignment = Alignment.TopCenter,
|
contentAlignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
F7AppMenuGridItem(
|
F7AppMenuGridItem(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = {
|
onClick = { if (originalIndex >= 0) onItemClick(originalIndex) },
|
||||||
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),
|
||||||
) {
|
) {
|
||||||
|
val localIcon = item.localIcon
|
||||||
|
if (localIcon != null) {
|
||||||
|
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(MenuIconSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White)
|
||||||
|
.border(1.5.dp, F7Colors.Green30, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
localIcon,
|
||||||
|
contentDescription = item.label,
|
||||||
|
tint = F7Colors.Primary,
|
||||||
|
modifier = Modifier.size(MenuIconSize * 0.46f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = item.iconUrl,
|
model = item.iconUrl,
|
||||||
contentDescription = item.label,
|
contentDescription = item.label,
|
||||||
modifier = Modifier.size(MenuIconSize),
|
modifier = Modifier.size(MenuIconSize),
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.labelLarge.copy(
|
style = MaterialTheme.typography.labelLarge.copy(
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ package ru.forbion.f7cloud.core.designsystem
|
|||||||
enum class F7BottomBarSlot {
|
enum class F7BottomBarSlot {
|
||||||
Chats,
|
Chats,
|
||||||
NavBack,
|
NavBack,
|
||||||
Create,
|
SectionMail,
|
||||||
Profile,
|
SectionCards,
|
||||||
Notifications,
|
SectionConferences,
|
||||||
Settings,
|
|
||||||
Menu,
|
Menu,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,72 +15,22 @@ data class F7BottomBarConfig(
|
|||||||
val buttonCount: Int get() = slots.size
|
val buttonCount: Int get() = slots.size
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
// Единая навигационная панель по дизайну «Новое меню»:
|
||||||
|
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
|
||||||
|
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
|
||||||
|
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun forContext(
|
fun forContext(
|
||||||
tabKey: String,
|
tabKey: String,
|
||||||
talkInRoom: Boolean,
|
talkInRoom: Boolean,
|
||||||
): F7BottomBarConfig = when (tabKey) {
|
): F7BottomBarConfig = F7BottomBarConfig(
|
||||||
"Talk" -> if (talkInRoom) {
|
|
||||||
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
|
|
||||||
} else {
|
|
||||||
F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Chats,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"Files" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
listOf(
|
||||||
F7BottomBarSlot.NavBack,
|
F7BottomBarSlot.NavBack,
|
||||||
F7BottomBarSlot.Profile,
|
F7BottomBarSlot.SectionMail,
|
||||||
F7BottomBarSlot.Notifications,
|
F7BottomBarSlot.SectionCards,
|
||||||
F7BottomBarSlot.Create,
|
F7BottomBarSlot.SectionConferences,
|
||||||
F7BottomBarSlot.Settings,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Contacts" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Create,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Tasks" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Create,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Support" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Create,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"Mail", "Calendar" -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.NavBack,
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Settings,
|
|
||||||
F7BottomBarSlot.Menu,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else -> F7BottomBarConfig(
|
|
||||||
listOf(
|
|
||||||
F7BottomBarSlot.Profile,
|
|
||||||
F7BottomBarSlot.Notifications,
|
|
||||||
F7BottomBarSlot.Menu,
|
F7BottomBarSlot.Menu,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,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,8 +175,15 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -136,6 +192,7 @@ fun F7ModuleScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun F7PrimaryButton(
|
fun F7PrimaryButton(
|
||||||
@@ -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,32 @@ 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 onCardsClick: () -> Unit = {},
|
||||||
val onNotificationsClick: () -> Unit = {},
|
val onConferencesClick: () -> Unit = {},
|
||||||
val onSettingsClick: () -> Unit = {},
|
|
||||||
val onMenuClick: () -> Unit = {},
|
val onMenuClick: () -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Живой forbion (_header.css .header__mobile-bottom): пилюля 328×61, паддинг 3,
|
||||||
|
// фон #F5F5F5; кнопки 55, фон #FBFBFB, рамка #E6E6E6; активная кнопка-раздел и открытый
|
||||||
|
// бургер — полупрозрачный зелёный градиент 104°.
|
||||||
|
private val BottomBarWidth = 328.dp
|
||||||
|
private val BottomBarHeight = 61.dp
|
||||||
private val BottomBarButtonSize = 55.dp
|
private val BottomBarButtonSize = 55.dp
|
||||||
private val BottomBarIconSize = 24.dp
|
private val BottomBarIconSize = 34.dp
|
||||||
private val BottomBarGap = 8.dp
|
private val BottomBarSectionIconSize = 46.dp
|
||||||
private val BottomBarOuterPaddingH = 6.dp
|
private val BottomBarOuterPadding = 3.dp
|
||||||
private val BottomBarOuterPaddingV = 6.dp
|
|
||||||
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
||||||
private val BottomBarBorderBrush = Brush.linearGradient(
|
private val BottomBarButtonBg = Color(0xFFFBFBFB)
|
||||||
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 +57,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 +65,11 @@ fun F7MobileBottomBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val base = serverUrl.trimEnd('/')
|
val base = serverUrl.trimEnd('/')
|
||||||
val pillShape = RoundedCornerShape(percent = 50)
|
val pillShape = RoundedCornerShape(100.dp)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.wrapContentWidth()
|
.width(BottomBarWidth)
|
||||||
|
.height(BottomBarHeight)
|
||||||
.shadow(
|
.shadow(
|
||||||
elevation = 2.dp,
|
elevation = 2.dp,
|
||||||
shape = pillShape,
|
shape = pillShape,
|
||||||
@@ -71,11 +77,8 @@ fun F7MobileBottomBar(
|
|||||||
)
|
)
|
||||||
.clip(pillShape)
|
.clip(pillShape)
|
||||||
.background(Color(0xFFF5F5F5))
|
.background(Color(0xFFF5F5F5))
|
||||||
.padding(
|
.padding(BottomBarOuterPadding),
|
||||||
horizontal = BottomBarOuterPaddingH,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
vertical = BottomBarOuterPaddingV,
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
config.slots.forEach { slot ->
|
config.slots.forEach { slot ->
|
||||||
@@ -87,36 +90,35 @@ 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 = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
|
||||||
contentDescription = "Папки",
|
contentDescription = "Папки",
|
||||||
highlighted = navBackHighlighted,
|
|
||||||
iconRotation = if (navBackHighlighted) 180f else 0f,
|
iconRotation = if (navBackHighlighted) 180f else 0f,
|
||||||
onClick = actions.onNavBackClick,
|
onClick = actions.onNavBackClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
|
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg",
|
||||||
contentDescription = "Создать",
|
contentDescription = "Почта",
|
||||||
onClick = actions.onCreateClick,
|
iconSize = BottomBarSectionIconSize,
|
||||||
|
active = activeTabKey.equals("mail", ignoreCase = true),
|
||||||
|
onClick = actions.onMailClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
|
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg",
|
||||||
contentDescription = "Профиль",
|
contentDescription = "Карточки",
|
||||||
onClick = actions.onProfileClick,
|
iconSize = BottomBarSectionIconSize,
|
||||||
|
active = activeTabKey.equals("deck", ignoreCase = true),
|
||||||
|
onClick = actions.onCardsClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
|
iconUrl = "$base/themes/forbion/images/header/spreed-header-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 = if (menuOpen) {
|
||||||
@@ -125,7 +127,8 @@ fun F7MobileBottomBar(
|
|||||||
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
"$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 +141,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 +175,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 out
|
||||||
return map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,24 +1,58 @@
|
|||||||
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"
|
||||||
|
return clients.getOrPut(key) {
|
||||||
|
base.newBuilder() // общий пул/диспатчер/кэш базового клиента
|
||||||
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
|
.callTimeout(callTimeoutSeconds, TimeUnit.SECONDS)
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
|
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
|
||||||
.applyUnsafeSslIfNeeded(trustAllCerts)
|
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||||
.addInterceptor(BasicAuthInterceptor(username, appPassword))
|
.addInterceptor(BasicAuthInterceptor(username, appPassword))
|
||||||
|
.apply { if (throwOnUnauthorized) addInterceptor(UnauthorizedInterceptor) }
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */
|
/** Collabora / richdocuments: cold start and WOPI can be slow on mobile networks. */
|
||||||
fun newAuthedClientForOffice(
|
fun newAuthedClientForOffice(
|
||||||
|
|||||||
@@ -30,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,14 +118,60 @@ 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() }
|
||||||
|
advanceQueueLocked(context, prefs, queue)
|
||||||
|
}
|
||||||
|
ended?.let { notifyCallEnded(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
|
||||||
|
* показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
|
||||||
|
* Принятые/отклонённые звонки сюда не попадают — их снимает [dismissAndShowNext].
|
||||||
|
*/
|
||||||
|
fun timeoutActive(context: Context, roomToken: String?) {
|
||||||
|
var ended: String? = null
|
||||||
|
synchronized(lock) {
|
||||||
|
val prefs = prefs(context)
|
||||||
|
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||||
|
if (active.isEmpty()) return
|
||||||
|
if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
|
||||||
|
|
||||||
|
val call = readActiveCall(prefs)
|
||||||
|
cancelTimeout(context)
|
||||||
|
cancelNotification(context)
|
||||||
|
clearActive(prefs)
|
||||||
|
ended = active
|
||||||
|
Log.i(TAG, "Call timed out (unanswered): $active")
|
||||||
|
if (call != null) {
|
||||||
|
showMissedNotification(context, call)
|
||||||
|
}
|
||||||
|
advanceQueueLocked(context, prefs, readQueue(prefs))
|
||||||
|
}
|
||||||
|
ended?.let { notifyCallEnded(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val prefs = prefs(context)
|
||||||
|
cancelTimeout(context)
|
||||||
|
cancelNotification(context)
|
||||||
|
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun advanceQueueLocked(
|
||||||
|
context: Context,
|
||||||
|
prefs: android.content.SharedPreferences,
|
||||||
|
queue: JSONArray,
|
||||||
|
) {
|
||||||
if (queue.length() == 0) {
|
if (queue.length() == 0) {
|
||||||
prefs.edit().remove(KEY_QUEUE).apply()
|
prefs.edit().remove(KEY_QUEUE).apply()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
val next = PendingCall.fromJson(queue.getJSONObject(0))
|
||||||
val rest = JSONArray()
|
val rest = JSONArray()
|
||||||
@@ -104,22 +180,14 @@ object F7IncomingCallQueue {
|
|||||||
}
|
}
|
||||||
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
|
||||||
if (showNotification(context, next, rest.length())) {
|
if (showNotification(context, next, rest.length())) {
|
||||||
setActive(prefs, next.roomToken)
|
setActive(prefs, next)
|
||||||
|
scheduleTimeout(context, next.roomToken)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Log.w(TAG, "Failed to parse queued call", it)
|
Log.w(TAG, "Failed to parse queued call", it)
|
||||||
prefs.edit().remove(KEY_QUEUE).apply()
|
prefs.edit().remove(KEY_QUEUE).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun clearAll(context: Context) {
|
|
||||||
synchronized(lock) {
|
|
||||||
val prefs = prefs(context)
|
|
||||||
cancelNotification(context)
|
|
||||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showNotification(
|
private fun showNotification(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -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 ->
|
||||||
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(F7Colors.Grey2),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
modifier = Modifier.weight(1f),
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
textAlign = TextAlign.Center,
|
fontWeight = FontWeight.Medium,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
color = F7Colors.TextPrimary,
|
||||||
color = F7Colors.TextMuted,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,63 +456,86 @@ 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,
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(if (selected) F7Colors.PrimaryLight else Color(0xFFFDFDFD))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(2.dp),
|
.padding(4.dp),
|
||||||
contentAlignment = Alignment.TopCenter,
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
Text(
|
if (isToday) {
|
||||||
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()) {
|
|
||||||
Text(
|
|
||||||
preview,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = F7Colors.PrimaryDark,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(top = 1.dp),
|
|
||||||
)
|
|
||||||
} 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
|
||||||
@@ -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,20 +79,32 @@ 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) },
|
||||||
|
) {
|
||||||
|
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
|
||||||
|
// панели, теперь — из шапки, панель стала навигационной)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
ContactsSearchBar(
|
ContactsSearchBar(
|
||||||
serverUrl = session.serverUrl,
|
serverUrl = session.serverUrl,
|
||||||
query = state.searchQuery,
|
query = state.searchQuery,
|
||||||
onQueryChange = vm::setSearchQuery,
|
onQueryChange = vm::setSearchQuery,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (state.syncing && state.contacts.isNotEmpty()) {
|
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
|
||||||
Text(
|
|
||||||
"Обновление…",
|
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = F7Colors.TextSecondary,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = state.syncing && state.contacts.isNotEmpty(),
|
||||||
|
onRefresh = { vm.refresh(session, force = true) },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
|
||||||
ContactListRow(
|
ContactListRow(
|
||||||
@@ -99,6 +115,7 @@ fun ContactsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.selectedContact?.let { contact ->
|
state.selectedContact?.let { contact ->
|
||||||
ContactDetailSheet(
|
ContactDetailSheet(
|
||||||
@@ -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,219 @@
|
|||||||
package ru.forbion.f7cloud.feature.deck
|
package ru.forbion.f7cloud.feature.deck
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck через внутренний фронт-API (`/index.php/apps/deck/api/v1.0`) — тот же, что у веб-версии.
|
||||||
|
* Пишущие запросы авторизуются app-password + OCS-APIRequest (CSRF при app-password не требуется).
|
||||||
|
*/
|
||||||
class DeckRepository {
|
class DeckRepository {
|
||||||
private fun apiBase(session: AuthSession): String {
|
private fun apiBase(session: AuthSession): String =
|
||||||
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
"${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
|
||||||
}
|
|
||||||
|
private fun client(session: AuthSession): OkHttpClient =
|
||||||
|
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||||
|
|
||||||
|
// --- Чтение ---
|
||||||
|
|
||||||
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
|
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
|
||||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
val json = getJson(client(session), "${apiBase(session)}/boards")
|
||||||
val json = getJson(client, "${apiBase(session)}/boards")
|
val array = json as? JSONArray ?: (json as? JSONObject)?.let { JSONArray().put(it) } ?: JSONArray()
|
||||||
val array = when (json) {
|
|
||||||
is JSONArray -> json
|
|
||||||
is JSONObject -> JSONArray().put(json)
|
|
||||||
else -> JSONArray()
|
|
||||||
}
|
|
||||||
val out = mutableListOf<DeckBoard>()
|
val out = mutableListOf<DeckBoard>()
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val board = array.optJSONObject(i) ?: continue
|
val board = array.optJSONObject(i) ?: continue
|
||||||
val id = board.optInt("id", 0)
|
val id = board.optInt("id", 0)
|
||||||
val title = board.optString("title")
|
if (id > 0 && !board.optBoolean("archived", false)) {
|
||||||
if (id > 0 && title.isNotBlank()) {
|
out += DeckBoard(
|
||||||
out += DeckBoard(id = id, title = title, color = board.optString("color"))
|
id = id,
|
||||||
|
title = board.optString("title"),
|
||||||
|
color = board.optString("color"),
|
||||||
|
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
|
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
|
||||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
val json = getJson(client(session), "${apiBase(session)}/cards/$cardId") as JSONObject
|
||||||
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
|
|
||||||
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
|
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
|
||||||
if (boardId <= 0) error("Карточка не найдена")
|
if (boardId <= 0) error("Карточка не найдена")
|
||||||
return DeckCardDetail(
|
return DeckCardDetail(cardId = cardId, boardId = boardId, title = json.optString("title"))
|
||||||
cardId = cardId,
|
|
||||||
boardId = boardId,
|
|
||||||
title = json.optString("title"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
||||||
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
val c = client(session)
|
||||||
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks")
|
val board = getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject
|
||||||
val stacksArray = when (stacksJson) {
|
val labels = parseLabels(board.optJSONArray("labels"))
|
||||||
is JSONArray -> stacksJson
|
val canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
|
||||||
else -> JSONArray()
|
val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
|
||||||
}
|
|
||||||
val stacks = mutableListOf<DeckStack>()
|
val stacks = mutableListOf<DeckStack>()
|
||||||
for (i in 0 until stacksArray.length()) {
|
for (i in 0 until stacksJson.length()) {
|
||||||
val stack = stacksArray.optJSONObject(i) ?: continue
|
val stack = stacksJson.optJSONObject(i) ?: continue
|
||||||
val stackId = stack.optInt("id", 0)
|
val stackId = stack.optInt("id", 0)
|
||||||
val title = stack.optString("title")
|
if (stackId <= 0) continue
|
||||||
val cards = mutableListOf<DeckCard>()
|
|
||||||
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
|
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
|
||||||
for (c in 0 until cardsArray.length()) {
|
val cards = mutableListOf<DeckCard>()
|
||||||
val card = cardsArray.optJSONObject(c) ?: continue
|
for (cIdx in 0 until cardsArray.length()) {
|
||||||
val cardTitle = card.optString("title")
|
cardsArray.optJSONObject(cIdx)?.let { cards += parseCard(it, stackId) }
|
||||||
if (cardTitle.isNotBlank()) {
|
}
|
||||||
cards += DeckCard(
|
stacks += DeckStack(
|
||||||
id = card.optInt("id", 0),
|
id = stackId,
|
||||||
title = cardTitle,
|
boardId = boardId,
|
||||||
done = card.has("done") && !card.isNull("done"),
|
title = stack.optString("title").ifBlank { "Колонка" },
|
||||||
|
order = stack.optInt("order", i),
|
||||||
|
cards = cards.sortedBy { it.order },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
return DeckBoardDetail(
|
||||||
if (stackId > 0) {
|
boardId = boardId,
|
||||||
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
|
title = board.optString("title"),
|
||||||
}
|
canEdit = canEdit,
|
||||||
}
|
labels = labels,
|
||||||
return DeckBoardDetail(boardId = boardId, stacks = stacks)
|
stacks = stacks.sortedBy { it.order },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
|
private fun parseCard(card: JSONObject, stackId: Int): DeckCard = DeckCard(
|
||||||
val request = Request.Builder().url(url).build()
|
id = card.optInt("id", 0),
|
||||||
client.newCall(request).execute().use { response ->
|
title = card.optString("title"),
|
||||||
if (response.code == 401) throw UnauthorizedException()
|
done = !card.isNull("done") && card.optString("done").isNotBlank(),
|
||||||
if (!response.isSuccessful || response.body == null) {
|
description = card.optString("description"),
|
||||||
error("Deck API HTTP ${response.code}")
|
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 {
|
||||||
|
val request = Request.Builder().url(url).header("OCS-APIRequest", "true").build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
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 +222,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 +243,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>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||