Compare commits
28 Commits
483e4107d7
...
fff360fe39
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
@@ -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,12 +624,35 @@ fun AppScaffold(
|
||||
onItemClick = { index ->
|
||||
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
|
||||
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 {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||
}
|
||||
menuOpen = false
|
||||
} else {
|
||||
}
|
||||
else -> {
|
||||
appTabFromMenuIndex(index)?.let { tab ->
|
||||
if (tab != activeTab) {
|
||||
pushTabHistory(activeTab)
|
||||
@@ -651,6 +661,7 @@ fun AppScaffold(
|
||||
}
|
||||
menuOpen = false
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
ProfileSheet(
|
||||
@@ -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,48 +83,180 @@ 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)
|
||||
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка 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 = 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,
|
||||
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
F7PrimaryButton(
|
||||
text = "Сканировать QR браузера",
|
||||
onClick = {
|
||||
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()
|
||||
},
|
||||
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()
|
||||
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
|
||||
fun NotificationsSheet(
|
||||
visible: Boolean,
|
||||
|
||||
+79
-8
@@ -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,6 +91,26 @@ fun F7AppMenuSheet(
|
||||
}
|
||||
val base = serverUrl.trimEnd('/')
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Затемнение над листом — тап закрывает меню (лист занимает только низ экрана)
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.18f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Сам лист — выезжает снизу вверх на ~пол-экрана, скруглённый верх + тень
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(250)) + slideInVertically(
|
||||
@@ -88,30 +121,45 @@ fun F7AppMenuSheet(
|
||||
animationSpec = tween(300),
|
||||
targetOffsetY = { it },
|
||||
),
|
||||
modifier = modifier,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.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 = 16.dp, bottom = 8.dp),
|
||||
.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 = 24.dp),
|
||||
.padding(bottom = 20.dp),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
columns = GridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = filteredItems,
|
||||
@@ -136,6 +184,7 @@ fun F7AppMenuSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F7AppMenuSearchField(
|
||||
@@ -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),
|
||||
) {
|
||||
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(
|
||||
|
||||
+12
-63
@@ -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(
|
||||
): F7BottomBarConfig = 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.SectionMail,
|
||||
F7BottomBarSlot.SectionCards,
|
||||
F7BottomBarSlot.SectionConferences,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -570,7 +570,10 @@ fun F7AppScaffold(
|
||||
content(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.f7SafeTopInsets(),
|
||||
// Отступы под статус-бар (сверху) И системную навигацию (снизу),
|
||||
// иначе на edge-to-edge (targetSdk 36) контент залезает под панели.
|
||||
.f7SafeTopInsets()
|
||||
.f7SafeBottomInsets(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
|
||||
+53
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+30
-39
@@ -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,
|
||||
)
|
||||
|
||||
+24
@@ -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
@@ -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-сборки делаются вручную на билд-машине тем же ключом.
|
||||
|
||||
+71
-53
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -388,46 +387,55 @@ 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(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
weekDayLabels.forEach { label ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(F7Colors.Grey2)
|
||||
.padding(vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextMuted,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Гридлайн-сетка без скруглений (по макету)
|
||||
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 selected = day == selectedDay
|
||||
val hasEvents = daysWithEvents.contains(day)
|
||||
val dayEvents = eventsByDay[day].orEmpty()
|
||||
CalendarDayCell(
|
||||
day = day.dayOfMonth,
|
||||
inMonth = inMonth,
|
||||
isToday = day == today,
|
||||
selected = selected,
|
||||
hasEvents = hasEvents,
|
||||
preview = dayEvents.firstOrNull()?.summary.orEmpty(),
|
||||
selected = day == selectedDay,
|
||||
events = dayEvents,
|
||||
onClick = { onSelectDay(day) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -436,6 +444,7 @@ fun CalendarMonthGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarDayCell(
|
||||
@@ -443,63 +452,72 @@ 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),
|
||||
) {
|
||||
// Номер дня сверху-слева; сегодня — в зелёном круге
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.then(if (isToday) Modifier.clip(CircleShape).background(F7Colors.Primary) else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
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.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,
|
||||
modifier = Modifier.padding(top = 1.dp),
|
||||
)
|
||||
} else if (hasEvents && inMonth) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(5.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.Primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -343,7 +343,6 @@ fun CalendarScreen(
|
||||
CalendarMonthGrid(
|
||||
month = state.visibleMonth,
|
||||
selectedDay = state.selectedDay,
|
||||
daysWithEvents = vm.daysWithEvents(),
|
||||
eventsByDay = eventsByDay,
|
||||
onSelectDay = vm::selectDay,
|
||||
)
|
||||
|
||||
+15
-3
@@ -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
|
||||
@@ -75,12 +76,24 @@ fun ContactsScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.contacts.isEmpty(),
|
||||
error = state.error,
|
||||
) {
|
||||
// Поиск + кнопка создания контакта (создание раньше вызывалось из нижней
|
||||
// панели, теперь — из шапки, панель стала навигационной)
|
||||
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(
|
||||
metaParts.joinToString(" • "),
|
||||
ownerLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
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,
|
||||
) {
|
||||
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))
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(2.dp)
|
||||
.width(72.dp)
|
||||
.clip(RoundedCornerShape(1.dp))
|
||||
.background(if (selected) F7Colors.Primary else Color.Transparent),
|
||||
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,
|
||||
) {
|
||||
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 |
Reference in New Issue
Block a user