28 Commits

Author SHA1 Message Date
b-dev-mobile fff360fe39 build: версия 0.5.123 (131) — первый тегированный релиз (FLAG_SECURE + CI)
CI / verify (push) Has been skipped
CI / release (push) Failing after 6s
2026-07-09 03:38:26 +00:00
b-dev-mobile d047dea210 docs/CI: раннер развёрнут на инфре B (repo-scope, host-исполнение)
act_runner v0.2.11 + Node 20 + Docker + systemd, метки self-hosted/f7-android
на хосте, тёплый кэш /root/.gradle. Зарегистрирован в scope репозитория
(repo-токен; site-admin токен подхватывал чужую ячейку glb_adm — не годится).
2026-07-09 03:20:32 +00:00
b-dev-mobile cfa9fe2ffd security: FLAG_SECURE на экранах входа и PIN (аудит п.3)
Новый эффект F7SecureScreen(): пока экран на виду — окно с FLAG_SECURE
(нет в скриншотах/recents/записи экрана), снимается при уходе. Повешен на
LoginScreen (ввод пароля), AppLockUnlockScreen (разблокировка PIN) и
AppLockSetupDialog (создание PIN). Закрывает хвост Этапа 0 п.3.
2026-07-09 02:50:57 +00:00
b-dev-mobile e25dcbc403 build: версия 0.5.122 (130) — убраны чат-кнопка и обновление в Конференциях 2026-07-08 17:45:54 +00:00
b-dev-mobile 342e1e497d ui: убрана кнопка «чаты» в панели и кнопка обновления в Конференциях
- Нижняя панель: слева всегда «назад» (кнопки чатов больше нет; в
  Конференциях раньше был чат-слот) — единый layout во всех разделах.
- Экран Конференций: убран onRefresh у F7ModuleScreen → нет кнопки ↻
  сверху справа (список грузится при открытии и по push).
2026-07-08 17:45:34 +00:00
b-dev-mobile 6f82a3fc56 build: версия 0.5.121 (129) — без зелёной подложки в панели 2026-07-08 17:39:50 +00:00
b-dev-mobile fe330be510 bottombar: убрана зелёная подложка под активной кнопкой
Фон кнопок всегда нейтральный (0x99FFFFFF); убраны highlight-подложка,
BottomBarHighlightBrush и параметр highlighted слота. Зелёные варианты
иконок чатов/бургера (при открытии) — это иконка, остаются.
2026-07-08 17:39:14 +00:00
b-dev-mobile 58d56feb76 build: версия 0.5.120 (128) — крупные иконки разделов в панели 2026-07-08 17:33:21 +00:00
b-dev-mobile 58eed74b03 bottombar: иконки-разделы крупнее (46dp) — заполняют кружок
У серверных SVG (mail-/deck-/spreed-header-icon) внутренний «воздух»,
поэтому глиф оставался мелким. Отдельный размер BottomBarSectionIconSize=46dp
для Почты/Карточек/Конференций (стрелка/бургер — прежние 34dp).
2026-07-08 17:32:38 +00:00
b-dev-mobile 8186031676 build: версия 0.5.119 (127) — без чекбокса сертификата на входе 2026-07-08 16:53:03 +00:00
b-dev-mobile 9e5a16a605 login: убран чекбокс «Доверять сертификату» с экрана входа
Раньше он показывался только в debug (в release скрыт), теперь убран
совсем. trustAllCerts остаётся false по умолчанию (безопасно); trust-all
на уровне сети/WebView и так отключён в release (UnsafeSsl).
2026-07-08 16:48:02 +00:00
b-dev-mobile b56c9a5998 build: release на боевом ключе владельца, версия 0.5.118 (126)
Первая release-сборка подписана ключом владельца (не debug) — убирает
предупреждение Play Защиты. + иконки нижней панели 24→34dp.
2026-07-08 16:37:41 +00:00
b-dev-mobile d50eaaed3d bottombar: крупнее иконки в кружках (24dp → 34dp)
Иконки заполняли кружок только на ~44% — увеличены, чтобы «на весь кружок».
2026-07-08 16:32:48 +00:00
b-dev-mobile 3824ad86f2 build: версия 0.5.116(124) → 0.5.117(125) под ревью-сборку
Накоплено: нижняя навигац.панель + кнопки создания, меню (пол-экрана/
3 колонки/состав по дизайну), нативная панель профиля, нижний inset,
фикс ширины писем.
2026-07-08 15:39:25 +00:00
b-dev-mobile 89e32d2875 bottombar: навигационная панель по дизайну «Новое меню»
Панель стала единой навигационной: слева назад (в Конференциях — чаты),
далее ярлыки-разделы Почта · Карточки · Конференции (активный подсвечен),
справа выдвижное меню. Профиль/Уведомления/Настройки/Создать убраны из
панели (Профиль/Уведомления/Настройки — в выдвижном меню; бейдж
уведомлений перенесён на кнопку меню).

Создание раньше вызывалось ТОЛЬКО из панели → чтобы не потерять:
- новый общий компонент F7CreateButton (зелёный круг «+»);
- добавлен в шапку Файлов (→ меню создания) и Контактов (→ добавить контакт).
Задачи/Поддержка уже имели свои кнопки; Почта — «+» в шапке.

F7BottomBarConfig упрощён (единый layout), F7MobileBottomBar получил
слоты SectionMail/SectionCards/SectionConferences + activeTabKey.
2026-07-08 15:26:24 +00:00
b-dev-mobile a30af98a8d menu: состав пунктов точно по дизайну (+Уведомления/Настройки)
По дизайну выдвижного меню: Файлы/Календарь/Контакты/Карточки/Заметки +
Bitrix/1C/Личный кабинет + Уведомления/Настройки. Убраны Почта/Конференции/
Задачи/Поддержка (остаются в нижней панели).
- F7AppMenuItem: опц. localIcon (ImageVector); нативные пункты рисуются в
  круглом бейдже с зелёной обводкой (стиль glass) вместо серверной SVG;
- Уведомления → NotificationsSheet, Настройки → настройки активного таба,
  обработка по label в AppScaffold.onItemClick.
2026-07-08 13:18:45 +00:00
b-dev-mobile fda34a5b69 menu: сетка 3 колонки + подпись во всю ширину ячейки (по дизайну)
Дизайн выдвижного меню — 3 колонки (было 4), иконки-бейджи *-glass.svg
уже совпадают со стилем. Ячейка стала кликабельной целиком (clip+ripple),
подпись использует ширину ячейки (Личный кабинет/Уведомления не дробятся).
2026-07-08 12:56:17 +00:00
b-dev-mobile 3d9f3819d9 profile: нативная панель профиля вместо веб-ЛК forbion
«Личный кабинет» открывал внешнюю веб-ссылку → десктопный дропдаун
forbion, криво на мобильном. Теперь пункт открывает нативную панель
(ProfileSheet, bottom-card F7FloatingPanel):
- шапка: аватар-инициал + отображаемое имя (тянется из OCS cloud/user)
  + «Открыть профиль» (deep-link /u/<id>) + кнопка QR-скана браузера;
- пункты: Установить статус / Личные настройки / Учётные записи
  (deep-link в веб-настройки), О программе и что нового (нативный
  About-диалог с версией), Выйти (нативный logout, красный).
Material-иконки (extended уже в app). Точки входа: пункт меню
«Личный кабинет» + кнопка профиля в нижней панели. Компилируется.
2026-07-08 12:45:35 +00:00
b-dev-mobile 04f67e071b ui: контент не залезает под системную навигацию (нижний inset)
Edge-to-edge (targetSdk 36): контент был full-bleed без нижнего inset →
уезжал под системные 3 кнопки/жест-панель (плавающая нижняя панель свой
navigationBarsPadding имела, а контент — нет). Добавил f7SafeBottomInsets
к контенту в F7AppScaffold (верх уже был через f7SafeTopInsets).
2026-07-08 12:25:43 +00:00
b-dev-mobile b83d3dc872 mail: письма вписываются по ширине экрана (WebView useWideViewPort)
HTML-письма (широкие таблицы Битрикс/рассылок) уезжали за правый край:
WebView без useWideViewPort игнорировал viewport width=device-width и
раскладывал по ~980px. Включил useWideViewPort+loadWithOverviewMode
(вписывает/масштабирует в обзор) + пинч-зум. READER_CSS уже имел
overflow-x:hidden/table-layout:fixed — теперь работает как задумано.
2026-07-08 12:25:43 +00:00
b-dev-mobile 530663754a menu: лончер приложений → bottom-sheet на пол-экрана (v0.5.116/124)
F7AppMenuSheet был fillMaxSize с непрозрачным фоном (закрывал весь экран).
Теперь выезжает снизу и занимает ~пол-экрана: якорь BottomCenter,
fillMaxHeight(0.6f), скруглённый верх 20dp + тень, ручка-хендл, лёгкий
скрим над листом (тап закрывает), зазор под нижнюю панель, сетка иконок
скроллится. Экран под меню виден сверху. Бамп версии 0.5.115→0.5.116.
2026-07-08 12:14:36 +00:00
b-dev-mobile 8cee4d61bf build: бамп версии 0.5.114(122) → 0.5.115(123) под дизайн-веху
Стиль проекта: патч в третьем числе +1, versionCode +1. Дизайн-ревью-
сборки теперь несут отдельную версию, а не WIP-линию 0.5.114.
2026-07-08 10:51:50 +00:00
b-dev-mobile 2220fe4a89 changelog: рескин панели Файлов + месячной сетки Календаря 2026-07-08 10:18:02 +00:00
b-dev-mobile a727e2dbd1 calendar: рескин месячной сетки под макет
По «Календарь · Месяц»: дни недели — серые пилюли; сетка безрамочная с
гридлайнами (0.5dp) вместо скруглённых боксов; номер дня сверху-слева;
сегодня — в зелёном круге (Primary, белый текст); событие — зелёная
пилюля PrimaryLight (до 2 + «+N»), было одно превью/точка. Компактная
модель (месяц + список дня снизу) сохранена. Компилируется.
2026-07-08 10:15:41 +00:00
b-dev-mobile 335468b80f files: рескин панели свойств/доступа под макет (табы-пилюли, шапка, звезда)
Правая панель (FilesDetailsSheet) по макету «Правая панель свойства/доступ»:
- табы из текст+подчёркивание → две иконки-пилюли (Обсуждение 💬 / Доступ 👥),
  активная — зелёная заливка PrimaryLight;
- шапка: крестик в правом верхнем углу, чип владельца (аватар-инициал+логин),
  дата/размер справа, звезда-избранное (Icons Star/StarBorder, wired на
  vm.toggleFavorite) вместо строки «Владелец … • …»;
- кнопка отправки комментария → зелёный круг-градиент с белой стрелкой (было «→»).
Иконки — Material (extended уже в модуле). Компилируется.
2026-07-08 10:11:57 +00:00
b-dev-mobile b15a97d30e changelog: подложка чата + теги/экран сообщения почты 2026-07-08 09:39:07 +00:00
b-dev-mobile 79e6b4ec83 mail: точные цвета тегов из макета (Работа/Позже/Личное)
Сняты пипеткой с «Почта · Список сообщений» (Figma):
Работа #DAEDD6/#28A413, Позже #F1EEE0/#C1B464, Личное #EFE4FB/#B574F7.
Раньше были приблизительные зелёный/коричневый/фиолетовый.
2026-07-08 09:36:52 +00:00
b-dev-mobile 8c3d422c85 talk: подложка чата из дизайна (Подложка2), локальный webp
TalkChatBackground грузил chat-pattern.svg с сервера поверх плоского фона.
Заменил на бандл-ресурс R.drawable.chat_background (светлый градиент +
паттерн иконок из Figma, Подложка2) — офлайн и точно из макета.
PNG 481КБ → WebP 49КБ (q82). ContentScale.Crop, full-bleed.
2026-07-08 09:33:31 +00:00
23 changed files with 821 additions and 405 deletions
+34
View File
@@ -2,6 +2,40 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 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-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26
targetSdk 36
versionCode 122
versionName '0.5.114'
versionCode 131
versionName '0.5.123'
missingDimensionStrategy 'default', 'f7'
multiDexEnabled true
@@ -53,6 +53,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.mobile.R
@@ -145,6 +146,7 @@ fun AppLockSetupDialog(
onLockConfigured: () -> Unit = {},
) {
if (!visible) return
F7SecureScreen() // создание/подтверждение PIN — не в скриншотах/recents
val context = LocalContext.current
val activity = context.findFragmentActivity()
@@ -534,6 +536,7 @@ private fun AppLockUnlockScreen(
lockSession: Int,
onUnlocked: () -> Unit,
) {
F7SecureScreen() // экран разблокировки (PIN) — не в скриншотах/recents
val context = LocalContext.current
val activity = context.findFragmentActivity()
var pin by remember { mutableStateOf("") }
@@ -1,5 +1,8 @@
package ru.forbion.f7cloud.mobile.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import ru.forbion.f7cloud.core.designsystem.F7AppMenuItem
enum class AppTab(
@@ -29,16 +32,14 @@ private data class AppMenuEntry(
val webPath: String? = null,
)
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
private val coreAppMenuEntries = listOf(
AppMenuEntry(AppTab.Mail, "Почта", "mail-glass.svg"),
AppMenuEntry(AppTab.Files, "Файлы", "files-glass.svg"),
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar-glass.svg"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contact-glass.svg"),
AppMenuEntry(AppTab.Talk, "Конференции", "spreed-glass.svg"),
AppMenuEntry(AppTab.Deck, "Карточки", "deck-glass.svg"),
AppMenuEntry(AppTab.Tasks, "Задачи", "task-glass.svg"),
AppMenuEntry(null, "Заметки", "notes-glass.svg", webPath = "/apps/notes/"),
AppMenuEntry(AppTab.Support, "Поддержка", "icon-header-f7support.svg"),
)
data class AppMenuExternalSite(
@@ -71,7 +72,22 @@ fun appMenuItems(
externalUrl = site.openUrl,
)
}
return core + external
// Нативные пункты по дизайну — действия обрабатываются в AppScaffold по label
val native = listOf(
F7AppMenuItem(
label = "Уведомления",
iconUrl = "",
selected = false,
localIcon = Icons.Filled.Notifications,
),
F7AppMenuItem(
label = "Настройки",
iconUrl = "",
selected = false,
localIcon = Icons.Filled.Settings,
),
)
return core + external + native
}
fun appTabFromMenuIndex(index: Int): AppTab? {
@@ -25,7 +25,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -82,6 +81,7 @@ import ru.forbion.f7cloud.core.push.F7PushEventHub
import ru.forbion.f7cloud.core.push.F7PushRegistrar
import ru.forbion.f7cloud.core.designsystem.F7OverlayNavigationProvider
import ru.forbion.f7cloud.core.designsystem.F7AppMenuSheet
import ru.forbion.f7cloud.core.designsystem.F7SecureScreen
import ru.forbion.f7cloud.core.designsystem.F7AppScaffold
import ru.forbion.f7cloud.core.designsystem.F7AutoHideBottomBar
import ru.forbion.f7cloud.core.designsystem.F7BottomBarActions
@@ -483,6 +483,7 @@ fun AppScaffold(
serverUrl = currentSession.serverUrl,
userId = userId,
config = bottomBarConfig,
activeTabKey = activeTab.name,
menuOpen = menuOpen,
chatsHighlighted = activeTab == AppTab.Talk && !talkInRoom,
navBackHighlighted = (activeTab == AppTab.Mail && mailSidebarOpen) ||
@@ -499,36 +500,22 @@ fun AppScaffold(
else -> Unit
}
},
onCreateClick = {
when (activeTab) {
AppTab.Files -> {
if (F7AppPermissions.missing(context).isNotEmpty()) {
onRequestRuntimePermissions { filesUploadRequest++ }
} else {
filesUploadRequest++
}
}
AppTab.Contacts -> contactsCreateRequest++
AppTab.Tasks -> tasksCreateRequest++
AppTab.Support -> supportCreateRequest++
else -> Unit
onMailClick = {
if (activeTab != AppTab.Mail) {
pushTabHistory(activeTab)
activeTab = AppTab.Mail
}
},
onProfileClick = {
menuOpen = false
profileOpen = true
onCardsClick = {
if (activeTab != AppTab.Deck) {
pushTabHistory(activeTab)
activeTab = AppTab.Deck
}
},
onNotificationsClick = {
menuOpen = false
hasNotificationBadge = false
notificationsOpen = true
},
onSettingsClick = {
when (activeTab) {
AppTab.Mail -> mailSettingsOpen = true
AppTab.Calendar -> calendarSettingsRequest++
AppTab.Files -> filesSettingsOpen = true
else -> Unit
onConferencesClick = {
if (activeTab != AppTab.Talk) {
pushTabHistory(activeTab)
activeTab = AppTab.Talk
}
},
onMenuClick = { menuOpen = !menuOpen },
@@ -637,19 +624,43 @@ fun AppScaffold(
onItemClick = { index ->
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
val external = item.externalUrl
if (!external.isNullOrBlank()) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
val label = item.label.trim()
when {
// Профиль — нативная панель вместо веб-ЛК forbion
label.equals("Личный кабинет", ignoreCase = true) -> {
menuOpen = false
profileOpen = true
}
menuOpen = false
} else {
appTabFromMenuIndex(index)?.let { tab ->
if (tab != activeTab) {
pushTabHistory(activeTab)
activeTab = tab
// Нативные пункты по дизайну меню
label.equals("Уведомления", ignoreCase = true) -> {
menuOpen = false
hasNotificationBadge = false
notificationsOpen = true
}
label.equals("Настройки", ignoreCase = true) -> {
menuOpen = false
when (activeTab) {
AppTab.Mail -> mailSettingsOpen = true
AppTab.Calendar -> calendarSettingsRequest++
AppTab.Files -> filesSettingsOpen = true
else -> Unit
}
}
menuOpen = false
!external.isNullOrBlank() -> {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
}
menuOpen = false
}
else -> {
appTabFromMenuIndex(index)?.let { tab ->
if (tab != activeTab) {
pushTabHistory(activeTab)
activeTab = tab
}
}
menuOpen = false
}
}
},
)
@@ -679,6 +690,7 @@ fun AppScaffold(
private const val PERMISSIONS_PROMPTED_KEY = "runtime_permissions_prompted_v2"
@Composable
private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
F7SecureScreen() // ввод пароля — не в скриншотах/recents
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
@@ -879,24 +891,6 @@ private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
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 "Войти",
@@ -1,17 +1,38 @@
package ru.forbion.f7cloud.mobile.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -21,22 +42,30 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Credentials
import okhttp3.Request
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
import ru.forbion.f7cloud.core.network.F7Notification
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NotificationsRepository
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
@@ -54,46 +83,178 @@ fun ProfileSheet(
onLogout: () -> Unit,
onScanBrowserQr: () -> Unit = {},
) {
val context = LocalContext.current
val base = session.serverUrl.trimEnd('/')
val userId = session.davUserId ?: session.username
var displayName by remember(session.username) { mutableStateOf(userId) }
var aboutOpen by remember { mutableStateOf(false) }
// Отображаемое имя тянем с сервера (OCS), пока — логин
LaunchedEffect(visible, session.username) {
if (visible) displayName = fetchDisplayName(session).ifBlank { userId }
}
fun openWeb(path: String) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("$base$path")))
}
onDismiss()
}
F7FloatingPanel(
visible = visible,
onDismiss = onDismiss,
) {
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
Text(
text = session.username,
style = MaterialTheme.typography.bodyLarge,
color = F7Colors.TextPrimary,
)
Text(
text = session.serverUrl,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
session.davUserId?.let { davId ->
Text(
text = "ID: $davId",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка QR
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight),
contentAlignment = Alignment.Center,
) {
Text(
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.Primary,
)
}
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.clickable { openWeb("/u/$userId") }
.padding(vertical = 2.dp),
) {
Text(
displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Открыть профиль",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.Primary,
)
}
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.Grey2)
.clickable {
onDismiss()
onScanBrowserQr()
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.QrCodeScanner,
contentDescription = "Сканировать QR браузера",
tint = F7Colors.TextSecondary,
modifier = Modifier.size(22.dp),
)
}
}
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
Spacer(Modifier.height(4.dp))
ProfileMenuRow(Icons.Filled.TaskAlt, "Установить статус") { openWeb("/settings/user") }
ProfileMenuRow(Icons.Filled.Person, "Личные настройки") { openWeb("/settings/user") }
ProfileMenuRow(Icons.Filled.Group, "Учётные записи") { openWeb("/settings/user/security") }
ProfileMenuRow(Icons.Outlined.Info, "О программе и что нового") { aboutOpen = true }
ProfileMenuRow(
icon = Icons.AutoMirrored.Filled.Logout,
label = "Выйти",
tint = F7Colors.Error,
) {
onDismiss()
onLogout()
}
F7PrimaryButton(
text = "Сканировать QR браузера",
onClick = {
onDismiss()
onScanBrowserQr()
},
modifier = Modifier.fillMaxWidth(),
)
F7PrimaryButton(
text = "Выйти",
onClick = {
onDismiss()
onLogout()
},
modifier = Modifier.fillMaxWidth(),
)
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
}
if (aboutOpen) {
F7AboutDialog(onDismiss = { aboutOpen = false })
}
}
@Composable
private fun ProfileMenuRow(
icon: ImageVector,
label: String,
tint: Color = F7Colors.TextPrimary,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
Text(label, style = MaterialTheme.typography.bodyLarge, color = tint)
}
}
@Composable
private fun F7AboutDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val version = remember {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: ""
}
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) { Text("Закрыть") }
},
title = { Text("F7cloud Mobile") },
text = {
Column {
Text("Версия $version", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
Spacer(Modifier.height(8.dp))
Text(
"Нативный клиент F7cloud: почта, файлы, календарь, контакты, задачи, конференции, поддержка.",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
},
)
}
/** Отображаемое имя пользователя из OCS (fallback — пусто). */
private suspend fun fetchDisplayName(session: AuthSession): String = withContext(Dispatchers.IO) {
runCatching {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
JSONObject(body)
.optJSONObject("ocs")
?.optJSONObject("data")
?.optString("displayname")
.orEmpty()
}
}.getOrDefault("")
}
@Composable
@@ -9,10 +9,12 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -23,8 +25,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -35,8 +39,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -51,11 +57,18 @@ private val BottomBarReserve: Dp = 90.dp
private val MenuIconSize = 62.dp
private val MenuGridGap = 20.dp
// Меню-лист выезжает снизу и занимает ~пол-экрана (не весь), по требованию владельца
private const val MenuSheetHeightFraction = 0.6f
private val MenuSheetShape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
data class F7AppMenuItem(
val label: String,
val iconUrl: String,
val selected: Boolean,
val externalUrl: String? = null,
// Локальная иконка вместо серверной (для нативных пунктов — Уведомления/Настройки),
// рисуется в круглом бейдже с зелёной обводкой под стиль glass-иконок.
val localIcon: ImageVector? = null,
)
@Composable
@@ -78,41 +91,76 @@ fun F7AppMenuSheet(
}
val base = serverUrl.trimEnd('/')
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
animationSpec = tween(350),
initialOffsetY = { it },
),
exit = fadeOut(tween(200)) + slideOutVertically(
animationSpec = tween(300),
targetOffsetY = { it },
),
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = BottomBarReserve)
.navigationBarsPadding()
.background(F7Colors.Background)
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
Box(modifier = modifier.fillMaxSize()) {
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
modifier = Modifier.fillMaxSize(),
) {
F7AppMenuSearchField(
serverUrl = base,
value = searchQuery,
onValueChange = { searchQuery = it },
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.18f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
)
}
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) + slideInVertically(
animationSpec = tween(350),
initialOffsetY = { it },
),
exit = fadeOut(tween(200)) + slideOutVertically(
animationSpec = tween(300),
targetOffsetY = { it },
),
modifier = Modifier.align(Alignment.BottomCenter),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
contentPadding = PaddingValues(horizontal = 2.dp),
modifier = Modifier.fillMaxSize(),
.fillMaxHeight(MenuSheetHeightFraction)
.padding(bottom = BottomBarReserve)
.navigationBarsPadding()
.shadow(16.dp, MenuSheetShape, clip = false)
.clip(MenuSheetShape)
.background(F7Colors.Background)
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
) {
// Ручка-хендл
Box(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 2.dp, bottom = 10.dp)
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(F7Colors.Border),
)
F7AppMenuSearchField(
serverUrl = base,
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp),
)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
contentPadding = PaddingValues(horizontal = 2.dp),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
itemsIndexed(
items = filteredItems,
key = { index, item -> "${item.label}-$index" },
@@ -132,6 +180,7 @@ fun F7AppMenuSheet(
)
}
}
}
}
}
}
@@ -195,17 +244,39 @@ private fun F7AppMenuGridItem(
) {
Column(
modifier = Modifier
.width(MenuIconSize)
.clickable(onClick = onClick),
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize),
contentScale = ContentScale.Fit,
)
val localIcon = item.localIcon
if (localIcon != null) {
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
Box(
modifier = Modifier
.size(MenuIconSize)
.clip(CircleShape)
.background(Color.White)
.border(1.5.dp, F7Colors.Green30, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
localIcon,
contentDescription = item.label,
tint = F7Colors.Primary,
modifier = Modifier.size(MenuIconSize * 0.46f),
)
}
} else {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize),
contentScale = ContentScale.Fit,
)
}
Text(
text = item.label,
style = MaterialTheme.typography.labelLarge.copy(
@@ -3,10 +3,9 @@ package ru.forbion.f7cloud.core.designsystem
enum class F7BottomBarSlot {
Chats,
NavBack,
Create,
Profile,
Notifications,
Settings,
SectionMail,
SectionCards,
SectionConferences,
Menu,
}
@@ -16,72 +15,22 @@ data class F7BottomBarConfig(
val buttonCount: Int get() = slots.size
companion object {
// Единая навигационная панель по дизайну «Новое меню»:
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
@Suppress("UNUSED_PARAMETER")
fun forContext(
tabKey: String,
talkInRoom: Boolean,
): F7BottomBarConfig = when (tabKey) {
"Talk" -> if (talkInRoom) {
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
} else {
F7BottomBarConfig(
listOf(
F7BottomBarSlot.Chats,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
}
"Files" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Create,
F7BottomBarSlot.Settings,
F7BottomBarSlot.Menu,
),
)
"Contacts" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Tasks" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Support" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Create,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
"Mail", "Calendar" -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Settings,
F7BottomBarSlot.Menu,
),
)
else -> F7BottomBarConfig(
listOf(
F7BottomBarSlot.Profile,
F7BottomBarSlot.Notifications,
F7BottomBarSlot.Menu,
),
)
}
): F7BottomBarConfig = F7BottomBarConfig(
listOf(
F7BottomBarSlot.NavBack,
F7BottomBarSlot.SectionMail,
F7BottomBarSlot.SectionCards,
F7BottomBarSlot.SectionConferences,
F7BottomBarSlot.Menu,
),
)
}
}
@@ -570,7 +570,10 @@ fun F7AppScaffold(
content(
Modifier
.fillMaxSize()
.f7SafeTopInsets(),
// Отступы под статус-бар (сверху) И системную навигацию (снизу),
// иначе на edge-to-edge (targetSdk 36) контент залезает под панели.
.f7SafeTopInsets()
.f7SafeBottomInsets(),
)
}
Box(
@@ -0,0 +1,53 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Зелёная круглая кнопка «+» — единый элемент создания в шапках экранов
* (Почта/Файлы/Контакты/…). Градиент Primary, как у других зелёных кнопок.
*/
@Composable
fun F7CreateButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 44.dp,
) {
Box(
modifier = modifier
.size(size)
.shadow(3.dp, CircleShape, spotColor = F7Colors.Primary.copy(alpha = 0.25f))
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
),
)
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
text = "+",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Light,
)
}
}
@@ -21,21 +21,25 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
data class F7BottomBarActions(
val onChatsClick: () -> Unit = {},
val onNavBackClick: () -> Unit = {},
val onCreateClick: () -> Unit = {},
val onProfileClick: () -> Unit = {},
val onNotificationsClick: () -> Unit = {},
val onSettingsClick: () -> Unit = {},
val onMailClick: () -> Unit = {},
val onCardsClick: () -> Unit = {},
val onConferencesClick: () -> Unit = {},
val onMenuClick: () -> Unit = {},
)
private val BottomBarButtonSize = 55.dp
private val BottomBarIconSize = 24.dp
private val BottomBarIconSize = 34.dp
// Иконки-разделы (Почта/Карточки/Конференции) — серверные SVG с внутренним
// «воздухом», поэтому рендерим крупнее, чтобы заполняли кружок.
private val BottomBarSectionIconSize = 46.dp
private val BottomBarGap = 8.dp
private val BottomBarOuterPaddingH = 6.dp
private val BottomBarOuterPaddingV = 6.dp
@@ -43,9 +47,6 @@ private val BottomBarButtonShape = RoundedCornerShape(100.dp)
private val BottomBarBorderBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
)
private val BottomBarHighlightBrush = Brush.linearGradient(
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
)
@Composable
fun F7MobileBottomBar(
@@ -53,6 +54,7 @@ fun F7MobileBottomBar(
userId: String,
config: F7BottomBarConfig,
actions: F7BottomBarActions,
activeTabKey: String = "",
menuOpen: Boolean = false,
chatsHighlighted: Boolean = false,
navBackHighlighted: Boolean = false,
@@ -87,36 +89,31 @@ fun F7MobileBottomBar(
"$base/themes/forbion/images/header/chat-icon-gray.svg"
},
contentDescription = "Чаты",
highlighted = chatsHighlighted,
onClick = actions.onChatsClick,
)
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
contentDescription = "Папки",
highlighted = navBackHighlighted,
iconRotation = if (navBackHighlighted) 180f else 0f,
onClick = actions.onNavBackClick,
)
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
contentDescription = "Создать",
onClick = actions.onCreateClick,
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg",
contentDescription = "Почта",
iconSize = BottomBarSectionIconSize,
onClick = actions.onMailClick,
)
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
contentDescription = "Профиль",
onClick = actions.onProfileClick,
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg",
contentDescription = "Карточки",
iconSize = BottomBarSectionIconSize,
onClick = actions.onCardsClick,
)
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
contentDescription = "Уведомления",
showBadge = showNotificationBadge,
onClick = actions.onNotificationsClick,
)
F7BottomBarSlot.Settings -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/setting-menu-icon.svg",
contentDescription = "Настройки",
onClick = actions.onSettingsClick,
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg",
contentDescription = "Конференции",
iconSize = BottomBarSectionIconSize,
onClick = actions.onConferencesClick,
)
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
iconUrl = if (menuOpen) {
@@ -125,7 +122,7 @@ fun F7MobileBottomBar(
"$base/themes/forbion/images/header/menu-burger-gray.svg"
},
contentDescription = "Меню",
highlighted = menuOpen,
showBadge = showNotificationBadge,
onClick = actions.onMenuClick,
)
}
@@ -138,22 +135,16 @@ private fun F7BottomBarIconSlot(
iconUrl: String,
contentDescription: String,
onClick: () -> Unit,
highlighted: Boolean = false,
iconRotation: Float = 0f,
showBadge: Boolean = false,
iconSize: Dp = BottomBarIconSize,
) {
val bg = if (highlighted) BottomBarHighlightBrush else null
// Зелёной подложки под активной кнопкой быть не должно — фон всегда нейтральный.
Box(
modifier = Modifier
.size(BottomBarButtonSize)
.clip(BottomBarButtonShape)
.then(
if (bg != null) {
Modifier.background(bg, BottomBarButtonShape)
} else {
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
},
)
.background(Color(0x99FFFFFF), BottomBarButtonShape)
.border(
width = 1.dp,
brush = BottomBarBorderBrush,
@@ -170,7 +161,7 @@ private fun F7BottomBarIconSlot(
model = iconUrl,
contentDescription = contentDescription,
modifier = Modifier
.size(BottomBarIconSize)
.size(iconSize)
.graphicsLayer { rotationZ = iconRotation },
contentScale = ContentScale.Fit,
)
@@ -0,0 +1,24 @@
package ru.forbion.f7cloud.core.designsystem
import android.app.Activity
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
/**
* Пока этот экран на виду окно помечено FLAG_SECURE: содержимое не попадает
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
*/
@Composable
fun F7SecureScreen() {
val context = LocalContext.current
DisposableEffect(Unit) {
val window = (context as? Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
+16 -7
View File
@@ -17,13 +17,20 @@ Gradle-кэшем, а не контейнер (иначе WebRTC качаетс
как у билд-машины: **JDK 21** (`/usr/lib/jvm/java-21-openjdk-amd64`) + **Android SDK 36**
(`/opt/android-sdk`, platform android-36, build-tools 36.0.0).
Регистрация (нужен registration-токен из Gitea → Settings → Actions → Runners, **права Gitea-админа**):
Регистрация **repo-токен** из Gitea → репозиторий `root/f7cloud_mobile` → Settings → Actions →
Runners → Create new Runner (НЕ site-admin токен — тот регистрирует раннер общеинстансно и он
подхватывает джобы чужих ячеек; проверено — брал `glb_adm/f7_talk`). Метки `self-hosted`+`f7-android`
соответствуют `runs-on: [self-hosted, f7-android]`.
**Развёрнуто на инфре B (2026-07-09):** `act_runner` v0.2.11 + Node 20 (`/opt/nodejs`) + Docker
(`docker.io`, нужен act_runner для старта, но джобы идут на **хосте** — метки `:host`, тёплый кэш
`/root/.gradle`). Конфиг `/opt/act_runner/config.yaml` (labels `self-hosted:host`,`f7-android:host`,
`container.docker_host: "-"`). systemd-сервис `act_runner.service` (enabled, Restart=always,
env JAVA_HOME/ANDROID_HOME/PATH+node). Регистрация — repo-scope (только f7cloud_mobile).
```bash
# на машине раннера (инфра B):
act_runner register --instance https://git.f7cloud.ru --token <TOKEN> --labels f7-android
act_runner daemon
systemctl status act_runner # состояние
journalctl -u act_runner -f # логи
```
Метка `f7-android` соответствует `runs-on: [self-hosted, f7-android]` в workflow.
## Secrets репозитория (Gitea → Settings → Actions → Secrets)
@@ -43,5 +50,7 @@ scripts/package-source.sh dist/ # → dist/f7cloud-mobile-v<ver>-<rev>-sourc
## Статус
Workflow и скрипт готовы и в репо. **Не активны, пока не зарегистрирован раннер** (инфра-шаг ячейки B,
нужен Gitea-админ для токена). До активации: сборки — вручную на билд-машине, исходники — скриптом.
**Активно (2026-07-09):** self-hosted раннер `f7-b-android` зарегистрирован и запущен на инфре B
(scope — репозиторий, host-исполнение). Workflow срабатывает на push/PR (lint+тесты+assembleDebug).
Осталось для джобы `release` по тегу: завести Secrets репозитория (keystore base64 + пароли) —
пока release-сборки делаются вручную на билд-машине тем же ключом.
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -388,49 +387,59 @@ private fun CalendarIconButton(
fun CalendarMonthGrid(
month: YearMonth,
selectedDay: LocalDate,
daysWithEvents: Set<LocalDate>,
eventsByDay: Map<LocalDate, List<CalendarEventItem>>,
onSelectDay: (LocalDate) -> Unit,
) {
val days = CalendarRepository.monthGridDays(month)
val today = LocalDate.now()
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(F7Colors.Surface)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(modifier = Modifier.fillMaxWidth()) {
// Заголовок дней недели — серые пилюли (по макету «Календарь · Месяц»)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
weekDayLabels.forEach { label ->
Text(
text = label,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextMuted,
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Grey2)
.padding(vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextSecondary,
)
}
}
}
days.chunked(7).forEach { week ->
Row(modifier = Modifier.fillMaxWidth()) {
week.forEach { day ->
val inMonth = day.month == month.month
val selected = day == selectedDay
val hasEvents = daysWithEvents.contains(day)
val dayEvents = eventsByDay[day].orEmpty()
CalendarDayCell(
day = day.dayOfMonth,
inMonth = inMonth,
isToday = day == today,
selected = selected,
hasEvents = hasEvents,
preview = dayEvents.firstOrNull()?.summary.orEmpty(),
onClick = { onSelectDay(day) },
modifier = Modifier.weight(1f),
)
// Гридлайн-сетка без скруглений (по макету)
Column(
modifier = Modifier
.fillMaxWidth()
.border(0.5.dp, F7Colors.Grey3),
) {
days.chunked(7).forEach { week ->
Row(modifier = Modifier.fillMaxWidth()) {
week.forEach { day ->
val inMonth = day.month == month.month
val dayEvents = eventsByDay[day].orEmpty()
CalendarDayCell(
day = day.dayOfMonth,
inMonth = inMonth,
isToday = day == today,
selected = day == selectedDay,
events = dayEvents,
onClick = { onSelectDay(day) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@@ -443,65 +452,74 @@ private fun CalendarDayCell(
inMonth: Boolean,
isToday: Boolean,
selected: Boolean,
hasEvents: Boolean,
preview: String,
events: List<CalendarEventItem>,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val bg = when {
val cellBg = when {
isToday -> F7Colors.Green10
selected -> F7Colors.PrimaryLight
isToday -> F7Colors.PrimaryLight.copy(alpha = 0.55f)
else -> F7Colors.Surface
}
val borderColor = when {
selected -> F7Colors.Primary
isToday -> F7Colors.PrimaryDark
else -> F7Colors.BorderLight
}
Box(
Column(
modifier = modifier
.aspectRatio(1f)
.padding(2.dp)
.clip(RoundedCornerShape(8.dp))
.background(bg)
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
.heightIn(min = 66.dp)
.border(0.5.dp, F7Colors.Grey3)
.background(cellBg)
.clickable(onClick = onClick)
.padding(2.dp),
contentAlignment = Alignment.TopCenter,
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Номер дня сверху-слева; сегодня — в зелёном круге
Box(
modifier = Modifier
.size(22.dp)
.then(if (isToday) Modifier.clip(CircleShape).background(F7Colors.Primary) else Modifier),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
style = MaterialTheme.typography.bodySmall,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
fontWeight = if (isToday) FontWeight.SemiBold else FontWeight.Normal,
color = when {
isToday -> F7Colors.TextOnPrimary
!inMonth -> F7Colors.TextMuted
selected -> F7Colors.PrimaryDark
else -> F7Colors.TextPrimary
},
)
if (hasEvents && inMonth && preview.isNotBlank()) {
}
if (inMonth) {
events.take(2).forEach { event ->
CalendarDayEventPill(event.summary.ifBlank { "Событие" })
}
if (events.size > 2) {
Text(
preview,
"+${events.size - 2}",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.PrimaryDark,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 1.dp),
)
} else if (hasEvents && inMonth) {
Box(
modifier = Modifier
.padding(top = 2.dp)
.size(5.dp)
.clip(CircleShape)
.background(F7Colors.Primary),
color = F7Colors.Primary,
fontWeight = FontWeight.Medium,
)
}
}
}
}
@Composable
private fun CalendarDayEventPill(text: String) {
Text(
text = text,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(F7Colors.PrimaryLight)
.padding(horizontal = 4.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.PrimaryDark,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
fun CalendarWeekView(
selectedDay: LocalDate,
@@ -343,7 +343,6 @@ fun CalendarScreen(
CalendarMonthGrid(
month = state.visibleMonth,
selectedDay = state.selectedDay,
daysWithEvents = vm.daysWithEvents(),
eventsByDay = eventsByDay,
onSelectDay = vm::selectDay,
)
@@ -48,6 +48,7 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
@Composable
@@ -76,11 +77,23 @@ fun ContactsScreen(
loading = state.loading && state.contacts.isEmpty(),
error = state.error,
) {
ContactsSearchBar(
serverUrl = session.serverUrl,
query = state.searchQuery,
onQueryChange = vm::setSearchQuery,
)
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
// панели, теперь — из шапки, панель стала навигационной)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ContactsSearchBar(
serverUrl = session.serverUrl,
query = state.searchQuery,
onQueryChange = vm::setSearchQuery,
modifier = Modifier.weight(1f),
)
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
}
if (state.syncing && state.contacts.isNotEmpty()) {
Text(
"Обновление…",
@@ -297,12 +310,11 @@ private fun ContactsSearchBar(
serverUrl: String,
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val base = serverUrl.trimEnd('/')
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
modifier = modifier
.height(40.dp)
.shadow(2.dp, RoundedCornerShape(100.dp), spotColor = Color(0xFFCBCBCB))
.clip(RoundedCornerShape(100.dp))
@@ -21,9 +21,18 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
@@ -49,6 +58,7 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
@@ -998,10 +1008,10 @@ fun FilesDetailsSheet(
loading: Boolean,
onDismiss: () -> Unit,
onTabChange: (FileDetailsTab) -> Unit,
onToggleFavorite: () -> Unit,
onWebOnlyAction: (String) -> Unit,
) {
if (item == null) return
val base = serverUrl.trimEnd('/')
var commentDraft by remember(item.relativePath) { mutableStateOf("") }
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
Surface(
@@ -1016,9 +1026,26 @@ fun FilesDetailsSheet(
.fillMaxSize()
.padding(20.dp),
) {
// Крестик закрытия — правый верхний угол (по макету)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Close,
contentDescription = "Закрыть",
tint = F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
@@ -1029,52 +1056,86 @@ fun FilesDetailsSheet(
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
val metaParts = buildList {
item.size?.let { add(formatFileSize(it)) }
item.lastModified?.let {
add(
SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)),
Spacer(Modifier.height(8.dp))
// Чип владельца (аватар-инициал + логин) — по макету
Row(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.SurfaceMuted)
.padding(start = 4.dp, end = 10.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(F7Colors.PrimaryLight),
contentAlignment = Alignment.Center,
) {
Text(
ownerLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
style = MaterialTheme.typography.labelSmall,
color = F7Colors.Primary,
fontWeight = FontWeight.SemiBold,
)
}
add("Владелец $ownerLabel")
Text(
ownerLabel,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
metaParts.joinToString(""),
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
TextButton(onClick = onDismiss) {
Text("", style = MaterialTheme.typography.titleMedium)
Column(horizontalAlignment = Alignment.End) {
val dateSize = buildList {
item.lastModified?.let {
add(SimpleDateFormat("d MMMM", Locale("ru")).format(Date(it)))
}
item.size?.let { add(formatFileSize(it)) }
}
if (dateSize.isNotEmpty()) {
Text(
dateSize.joinToString(" / "),
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.height(8.dp))
}
Icon(
if (item.favorite) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = "Избранное",
tint = if (item.favorite) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.clickable(onClick = onToggleFavorite),
)
}
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
FilesDetailsTabButton(
serverUrl = serverUrl,
label = "Общий доступ",
iconPath = "files/many-person-gray.svg",
selected = tab == FileDetailsTab.Sharing,
onClick = { onTabChange(FileDetailsTab.Sharing) },
)
FilesDetailsTabButton(
serverUrl = serverUrl,
label = "События",
iconPath = "files/grid-files-gray.svg",
FilesDetailsTabPill(
icon = Icons.Filled.ChatBubbleOutline,
contentDescription = "Обсуждение",
selected = tab == FileDetailsTab.Events,
onClick = { onTabChange(FileDetailsTab.Events) },
modifier = Modifier.weight(1f),
)
FilesDetailsTabPill(
icon = Icons.Filled.Group,
contentDescription = "Общий доступ",
selected = tab == FileDetailsTab.Sharing,
onClick = { onTabChange(FileDetailsTab.Sharing) },
modifier = Modifier.weight(1f),
)
}
HorizontalDivider(
modifier = Modifier.padding(top = 8.dp),
color = F7Colors.Border.copy(alpha = 0.5f),
)
Spacer(Modifier.height(16.dp))
Box(
modifier = Modifier
.weight(1f)
@@ -1117,42 +1178,28 @@ fun FilesDetailsSheet(
}
@Composable
private fun FilesDetailsTabButton(
serverUrl: String,
label: String,
iconPath: String,
private fun FilesDetailsTabPill(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val base = serverUrl.trimEnd('/')
Column(
modifier = Modifier.clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
val shape = RoundedCornerShape(14.dp)
Box(
modifier = modifier
.height(40.dp)
.clip(shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface)
.border(1.dp, if (selected) Color.Transparent else F7Colors.Border, shape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AsyncImage(
model = "$base/themes/forbion/images/$iconPath",
contentDescription = null,
modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit,
)
Text(
label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) F7Colors.TextPrimary else F7Colors.TextSecondary,
)
}
Spacer(Modifier.height(8.dp))
Box(
modifier = Modifier
.height(2.dp)
.width(72.dp)
.clip(RoundedCornerShape(1.dp))
.background(if (selected) F7Colors.Primary else Color.Transparent),
Icon(
icon,
contentDescription = contentDescription,
tint = if (selected) F7Colors.Primary else F7Colors.TextSecondary,
modifier = Modifier.size(20.dp),
)
}
}
@@ -1348,8 +1395,24 @@ private fun FilesEventsTabContent(
modifier = Modifier.weight(1f),
onEnter = onSubmitComment,
)
TextButton(onClick = onSubmitComment) {
Text("", fontSize = 20.sp)
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
),
)
.clickable(onClick = onSubmitComment),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Отправить",
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
}
Text(
@@ -41,6 +41,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
@@ -189,6 +190,9 @@ fun FilesScreen(
FilesViewToggleButton(viewMode) {
viewMode = if (viewMode == FilesViewMode.List) FilesViewMode.Grid else FilesViewMode.List
}
// Создание (загрузка/новая папка) — раньше вызывалось из нижней панели,
// теперь кнопка в шапке (нижняя панель стала навигационной)
F7CreateButton(onClick = { showCreateMenu = true }, size = 40.dp)
}
// Хлебные крошки
val crumbSegments = state.currentPath.split("/").filter { it.isNotBlank() }
@@ -360,6 +364,7 @@ fun FilesScreen(
loading = state.detailsLoading,
onDismiss = vm::hideDetails,
onTabChange = vm::setDetailsTab,
onToggleFavorite = { state.detailsItem?.let { vm.toggleFavorite(session, it) } },
onWebOnlyAction = vm::showWebOnlyMessage,
)
@@ -710,10 +710,11 @@ private fun tagChipColors(tag: MailTag): TagChipColors {
text = parsed.copy(alpha = 0.95f),
)
}
// Точные цвета чипов из макета «Почта · Список сообщений» (Figma, сняты пипеткой)
return when (tag.displayName.lowercase(Locale("ru"))) {
"работа", "work" -> TagChipColors(Color(0xFFE8F5D6), Color(0xFF5A7A2E))
"позже", "later" -> TagChipColors(Color(0xFFF5F0E6), Color(0xFF8A7345))
"личное", "personal" -> TagChipColors(Color(0xFFEDE8F5), Color(0xFF6A558A))
"работа", "work" -> TagChipColors(Color(0xFFDAEDD6), Color(0xFF28A413))
"позже", "later" -> TagChipColors(Color(0xFFF1EEE0), Color(0xFFC1B464))
"личное", "personal" -> TagChipColors(Color(0xFFEFE4FB), Color(0xFFB574F7))
else -> TagChipColors(F7Colors.SurfaceMuted, F7Colors.TextSecondary)
}
}
@@ -70,6 +70,14 @@ fun MailMessageBodyView(
settings.domStorageEnabled = false
settings.loadsImagesAutomatically = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
// Вписывать письмо по ширине экрана: уважать viewport width=device-width
// и масштабировать широкие письма (таблицы Битрикс/рассылок) в обзорный вид,
// иначе WebView раскладывает по ~980px и контент уезжает за правый край.
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.setSupportZoom(true)
settings.builtInZoomControls = true
settings.displayZoomControls = false
isVerticalScrollBarEnabled = true
isNestedScrollingEnabled = true
setBackgroundColor(0x00000000)
@@ -1,6 +1,7 @@
package ru.forbion.f7cloud.feature.talk
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -47,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import okhttp3.Credentials
@@ -87,12 +89,13 @@ fun TalkChatBackground(
.fillMaxSize()
.background(F7Colors.ChatBackground),
) {
AsyncImage(
model = TalkAssets.spreed(session, "chat-pattern.svg"),
// Локальная подложка чата из дизайна (Подложка2): светло-зелёный градиент + водяной
// паттерн иконок. Была с сервера — теперь офлайн и точно из макета.
Image(
painter = painterResource(R.drawable.chat_background),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alpha = 0.35f,
)
content()
}
@@ -304,7 +304,6 @@ fun TalkScreen(
modifier = modifier,
loading = state.loading && state.rooms.isEmpty(),
error = state.error,
onRefresh = { vm.load(session) },
) {
RoomListContent(
session = session,
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB