Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91aaaba5a0 | |||
| e6a71de7a6 | |||
| 4b000d3b27 | |||
| f70b1861bd | |||
| 6cdef16952 | |||
| c7af5f7d11 | |||
| 2ec2115be7 | |||
| 3e5d0d4892 | |||
| c0703c3537 | |||
| 40e3a4da5c | |||
| a96eda5b7c | |||
| 1e39b2e2fe | |||
| e1e0427066 | |||
| 25cfbe9bdc | |||
| c9340ab2ab | |||
| cdb2471445 | |||
| a5ebcd4d80 | |||
| 374e8838e8 | |||
| 47b2638b2b | |||
| b8e08d1a97 | |||
| fd341cb22a |
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||||
|
|
||||||
|
- 2026-07-10 | v0.5.144 (152) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.133–143). **Whiteboard**: .whiteboard наконец открывается — реальная веб-сессия Nextcloud в WebView (login-flow app-паролем → cookie+CSRF), для web-файлов отключено OkHttp-проксирование (теряло тело POST/PROPFIND). **Календарь — большой пакет**: русские месяцы в шапке; тап по дате не крашит (нативный DatePickerDialog вместо material3); фикс 403 при создании события (учёт supported-calendar-component-set — событие не уходит в VTODO-коллекцию; в ошибках теперь sabre-precondition); окно события 1:1 с живой темой (pill-поля 40dp #FBFBFB/#E6E6E6, шапка с крестиком, зелёная галочка, высота 94% экрана); дата/время нативными пикерами; беседа Talk к событию (выбор существующей или создание публичной/приватной); вложения (ATTACH: «из файлов» с навигацией по WebDAV или загрузка с устройства в /Calendar; round-trip при редактировании); кастомное повторение (модалка: интервал+частота, кружки дней недели, окончание никогда/до даты/N раз ↔ честный RRULE); многодневные события (отдельная дата окончания); несколько напоминаний; управление календарём из сайдбара (✎: переименование, цвет, шаринг пользователям/группам через DAV oc:invite + OCS sharees, удаление с подтверждением; подписки read-only); discard-диалог при закрытии с несохранёнными изменениями; занятость участников (iTIP VFREEBUSY через DAV outbox, конфликты подсвечены). **Задачи** (из 133–137): подзадачи (RELATED-TO), меню «⋯» (вложенная задача/удаление), стрелка панели открывает списки, кэш ускоряет загрузку. **Файлы** (из 133–137): .md в нативном редакторе (WebDAV load/save + предпросмотр), прогрев WebView для docx/xlsx. **Меню**: иконки шторки в белых круглых рамках с тенью, единый размер. Push/звонки: 30-с таймаут → «Пропущенный звонок» (обезличенно на локскрине) | +NextcloudWebSession (POST /login); CalDAV: PROPPATCH/DELETE/share на календарях, POST outbox (VFREEBUSY), ATTACH в VEVENT; OCS: sharees, cloud/user(email), spreed room list | средний: пишущие операции календаря (шаринг/удаление/free-busy/вложения) проверены компиляцией, на устройстве не гонялись
|
||||||
|
|
||||||
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
|
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
|
||||||
|
|
||||||
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
|
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ android {
|
|||||||
applicationId 'ru.forbion.f7cloud.mobile'
|
applicationId 'ru.forbion.f7cloud.mobile'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 136
|
versionCode 152
|
||||||
versionName '0.5.128'
|
versionName '0.5.144'
|
||||||
missingDimensionStrategy 'default', 'f7'
|
missingDimensionStrategy 'default', 'f7'
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,31 @@ class F7MobileApp : F7cloudTalkApplication(), ImageLoaderFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(): ImageLoader {
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
// OkHttp с Basic-auth для хоста сервера: серверные картинки (иконки внешних сайтов
|
||||||
|
// /apps/external/img, превью и т.п.) требуют логина — без заголовка coil ловит 401.
|
||||||
|
val authedClient = okhttp3.OkHttpClient.Builder()
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
val session = runCatching { AuthStore(this).load() }.getOrNull()
|
||||||
|
val serverHost = session?.serverUrl?.let {
|
||||||
|
runCatching { android.net.Uri.parse(it).host }.getOrNull()
|
||||||
|
}
|
||||||
|
val patched = if (session != null && serverHost != null && request.url.host == serverHost) {
|
||||||
|
request.newBuilder()
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
okhttp3.Credentials.basic(session.username, session.appPassword),
|
||||||
|
)
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
}
|
||||||
|
chain.proceed(patched)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
return ImageLoader.Builder(this)
|
return ImageLoader.Builder(this)
|
||||||
|
.okHttpClient(authedClient)
|
||||||
.components { add(SvgDecoder.Factory()) }
|
.components { add(SvgDecoder.Factory()) }
|
||||||
.crossfade(false)
|
.crossfade(false)
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
|
|||||||
@@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.NextcloudWebSession
|
||||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||||
|
|
||||||
class OfficeEditorActivity : ComponentActivity() {
|
class OfficeEditorActivity : ComponentActivity() {
|
||||||
@@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() {
|
|||||||
if (current == next) return
|
if (current == next) return
|
||||||
editorVisible = false
|
editorVisible = false
|
||||||
loadError = null
|
loadError = null
|
||||||
view.loadUrl(target.url)
|
startLoad(view, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collabora — грузим сразу (iframe самодостаточен по WOPI-токену). Web-viewer (доска и пр.)
|
||||||
|
* требует НАСТОЯЩУЮ веб-сессию: сначала логинимся app-паролем, переносим cookie в
|
||||||
|
* WebView CookieManager, только потом открываем URL — иначе viewer видит анонима («нет файлов»).
|
||||||
|
*/
|
||||||
|
private fun startLoad(view: WebView, target: OfficeEditorLaunch) {
|
||||||
|
if (target.collaboraBaseUrl.isNotBlank()) {
|
||||||
|
view.loadUrl(target.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val ok = runCatching { establishWebSession(target) }.getOrDefault(false)
|
||||||
|
if (!ok) {
|
||||||
|
loadError = "Не удалось открыть сессию. Проверьте интернет и попробуйте снова."
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
view.loadUrl(target.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Логин по форме `/login`, перенос session-cookie в WebView. Возвращает true при успехе. */
|
||||||
|
private suspend fun establishWebSession(target: OfficeEditorLaunch): Boolean {
|
||||||
|
val serverUrl = target.serverUrl.ifBlank { target.url }
|
||||||
|
val cookies = withContext(Dispatchers.IO) {
|
||||||
|
NextcloudWebSession.login(
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
username = target.username,
|
||||||
|
appPassword = target.password,
|
||||||
|
trustAllCerts = target.trustAllCerts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (cookies.isEmpty()) return false
|
||||||
|
val cm = CookieManager.getInstance()
|
||||||
|
cm.setAcceptCookie(true)
|
||||||
|
webViewRef?.let { cm.setAcceptThirdPartyCookies(it, true) }
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
cookies.forEach { c ->
|
||||||
|
val attrs = buildString {
|
||||||
|
append("${c.name}=${c.value}; Path=${c.path}")
|
||||||
|
if (c.secure) append("; Secure")
|
||||||
|
if (c.httpOnly) append("; HttpOnly")
|
||||||
|
}
|
||||||
|
cm.setCookie(base, attrs)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) { cm.flush() }
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readLaunch(): OfficeEditorLaunch? {
|
private fun readLaunch(): OfficeEditorLaunch? {
|
||||||
@@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() {
|
|||||||
val current = webView.url?.trimEnd('/').orEmpty()
|
val current = webView.url?.trimEnd('/').orEmpty()
|
||||||
val target = launch.url.trimEnd('/')
|
val target = launch.url.trimEnd('/')
|
||||||
if (current != target) {
|
if (current != target) {
|
||||||
webView.loadUrl(launch.url)
|
startLoad(webView, launch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -346,7 +399,14 @@ class OfficeEditorActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
onPageReady = { view ->
|
onPageReady = { view ->
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||||
|
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||||
|
} else {
|
||||||
|
// Web-файлы (whiteboard и пр.): Collabora-моста нет — готовность = загрузка
|
||||||
|
// страницы; chrome НЕ прячем (иначе снесётся весь контейнер приложения).
|
||||||
|
editorVisible = true
|
||||||
|
loadError = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ internal class OfficeWebViewClient(
|
|||||||
private val onPageReady: (WebView?) -> Unit,
|
private val onPageReady: (WebView?) -> Unit,
|
||||||
) : WebViewClient() {
|
) : WebViewClient() {
|
||||||
|
|
||||||
|
// Скрытие chrome forbion — ТОЛЬКО для Collabora-страницы (там документ в отдельном
|
||||||
|
// #documents-content). На Files-странице (whiteboard и пр.) этот скрипт сносит #body-user
|
||||||
|
// = весь контейнер приложения → белый экран. Поэтому для web-файлов не инжектим.
|
||||||
|
private val hideChrome: Boolean get() = launch.collaboraBaseUrl.isNotBlank()
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||||
onPageReady(view)
|
onPageReady(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,11 +128,10 @@ internal class OfficeWebViewClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
||||||
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
|
// Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии
|
||||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
// (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files.
|
||||||
prefixes += launch.collaboraBaseUrl.trimEnd('/')
|
if (launch.collaboraBaseUrl.isBlank()) return emptyList()
|
||||||
}
|
return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct()
|
||||||
return prefixes.distinct()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ object AppMenuRepository {
|
|||||||
}
|
}
|
||||||
AppMenuExternalSite(
|
AppMenuExternalSite(
|
||||||
name = name,
|
name = name,
|
||||||
iconUrl = icon ?: defaultExternalIcon(session.serverUrl, name),
|
iconUrl = resolveExternalIcon(session.serverUrl, icon, name),
|
||||||
openUrl = openUrl,
|
openUrl = openUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -56,12 +56,18 @@ object AppMenuRepository {
|
|||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultExternalIcon(serverUrl: String, name: String): String {
|
/**
|
||||||
|
* Иконка внешнего сайта. API external отдаёт `icon` как ИМЯ ФАЙЛА (напр. «bitrix24.png»),
|
||||||
|
* а не URL — сайт грузит его с `/apps/external/img/{icon}` (см. layout.user.php). Раньше
|
||||||
|
* имя файла бралось как есть → битая ссылка → глобус-заглушка у Bitrix/1C.
|
||||||
|
*/
|
||||||
|
private fun resolveExternalIcon(serverUrl: String, icon: String?, name: String): String {
|
||||||
val base = serverUrl.trimEnd('/')
|
val base = serverUrl.trimEnd('/')
|
||||||
return when {
|
return when {
|
||||||
name.contains("bitrix", ignoreCase = true) -> "$base/themes/forbion/images/header/bitrix-glass.svg"
|
icon.isNullOrBlank() -> "$base/index.php/apps/external/img/external.svg"
|
||||||
name.contains("1c", ignoreCase = true) -> "$base/themes/forbion/images/header/1c-glass.svg"
|
icon.startsWith("http") -> icon
|
||||||
else -> "$base/index.php/apps/external/img/external.svg"
|
icon.startsWith("/") -> "$base$icon"
|
||||||
|
else -> "$base/index.php/apps/external/img/$icon"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ private data class AppMenuEntry(
|
|||||||
|
|
||||||
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
|
// Состав меню — по дизайну выдвижного меню (Почта/Конференции/Задачи/Поддержка — только
|
||||||
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
|
// в нижней панели; Уведомления/Настройки добавлены как нативные пункты, см. appMenuItems).
|
||||||
// iconPath — базовое имя плоской menu-иконки (assets/menu/menu-<name>.svg)
|
// iconPath — базовое имя плоской menu-иконки (assets/menu/menu-<name>.svg).
|
||||||
|
// Состав/порядок 1:1 со шторкой сайта: Файлы·Календарь·Контакты·Карточки·Заметки·Поддержка
|
||||||
|
// (Почта/Задачи/Конференции не тут — они в нижней панели).
|
||||||
private val coreAppMenuEntries = listOf(
|
private val coreAppMenuEntries = listOf(
|
||||||
AppMenuEntry(AppTab.Files, "Файлы", "files"),
|
AppMenuEntry(AppTab.Files, "Файлы", "files"),
|
||||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
|
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
|
||||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
|
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
|
||||||
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
|
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
|
||||||
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
|
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
|
||||||
|
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AppMenuExternalSite(
|
data class AppMenuExternalSite(
|
||||||
@@ -74,8 +77,14 @@ fun appMenuItems(
|
|||||||
externalUrl = site.openUrl,
|
externalUrl = site.openUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Нативные пункты — плоские menu-иконки темы, действия по label в AppScaffold
|
// Нативные пункты — плоские menu-иконки темы, действия по label в AppScaffold.
|
||||||
|
// Порядок как на сайте: после внешних сайтов — Личный кабинет, затем Уведомления, Настройки.
|
||||||
val native = listOf(
|
val native = listOf(
|
||||||
|
F7AppMenuItem(
|
||||||
|
label = "Личный кабинет",
|
||||||
|
iconUrl = menuIcon("lk"),
|
||||||
|
selected = false,
|
||||||
|
),
|
||||||
F7AppMenuItem(
|
F7AppMenuItem(
|
||||||
label = "Уведомления",
|
label = "Уведомления",
|
||||||
iconUrl = menuIcon("notifications"),
|
iconUrl = menuIcon("notifications"),
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ fun AppScaffold(
|
|||||||
var filesUploadRequest by remember { mutableIntStateOf(0) }
|
var filesUploadRequest by remember { mutableIntStateOf(0) }
|
||||||
var contactsCreateRequest by remember { mutableIntStateOf(0) }
|
var contactsCreateRequest by remember { mutableIntStateOf(0) }
|
||||||
var tasksCreateRequest by remember { mutableIntStateOf(0) }
|
var tasksCreateRequest by remember { mutableIntStateOf(0) }
|
||||||
|
var tasksShowListsRequest by remember { mutableIntStateOf(0) }
|
||||||
var supportCreateRequest by remember { mutableIntStateOf(0) }
|
var supportCreateRequest by remember { mutableIntStateOf(0) }
|
||||||
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
|
var calendarSettingsRequest by remember { mutableIntStateOf(0) }
|
||||||
var talkChatsRequest by remember { mutableIntStateOf(0) }
|
var talkChatsRequest by remember { mutableIntStateOf(0) }
|
||||||
@@ -458,7 +459,10 @@ fun AppScaffold(
|
|||||||
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
AppTab.Mail -> mailSidebarOpen = !mailSidebarOpen
|
||||||
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
AppTab.Calendar -> calendarSidebarOpen = !calendarSidebarOpen
|
||||||
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
AppTab.Files -> filesSidebarOpen = !filesSidebarOpen
|
||||||
// Разделы без сайдбара (Карточки/Конференции/Контакты/Задачи/
|
// Задачи: стрелка открывает панель списков (закрывает
|
||||||
|
// открытый список) — как сайдбар в Почте/Файлах.
|
||||||
|
AppTab.Tasks -> tasksShowListsRequest++
|
||||||
|
// Разделы без сайдбара (Карточки/Конференции/Контакты/
|
||||||
// Поддержка): стрелка = «назад» к предыдущему разделу.
|
// Поддержка): стрелка = «назад» к предыдущему разделу.
|
||||||
else -> popTabHistory()?.let { activeTab = it }
|
else -> popTabHistory()?.let { activeTab = it }
|
||||||
}
|
}
|
||||||
@@ -469,10 +473,10 @@ fun AppScaffold(
|
|||||||
activeTab = AppTab.Mail
|
activeTab = AppTab.Mail
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCardsClick = {
|
onTasksClick = {
|
||||||
if (activeTab != AppTab.Deck) {
|
if (activeTab != AppTab.Tasks) {
|
||||||
pushTabHistory(activeTab)
|
pushTabHistory(activeTab)
|
||||||
activeTab = AppTab.Deck
|
activeTab = AppTab.Tasks
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConferencesClick = {
|
onConferencesClick = {
|
||||||
@@ -566,6 +570,7 @@ fun AppScaffold(
|
|||||||
modifier = contentModifier,
|
modifier = contentModifier,
|
||||||
createRequest = tasksCreateRequest,
|
createRequest = tasksCreateRequest,
|
||||||
openListSlug = pendingTasksListSlug,
|
openListSlug = pendingTasksListSlug,
|
||||||
|
showListsRequest = tasksShowListsRequest,
|
||||||
onOpenListConsumed = { pendingTasksListSlug = null },
|
onOpenListConsumed = { pendingTasksListSlug = null },
|
||||||
onUnauthorized = forceLogout,
|
onUnauthorized = forceLogout,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_205_498)">
|
||||||
|
<path d="M35 17.5C35 22.3965 32.9889 26.823 29.748 29.999C29.7278 30.0189 29.7072 30.0387 29.6866 30.0585C29.4207 30.3164 29.1468 30.5662 28.8645 30.8073C28.3084 31.2838 27.7217 31.7251 27.1083 32.1287C24.3503 33.9441 21.0484 35 17.5 35C13.9516 35 10.6497 33.9441 7.89171 32.1287C7.27832 31.7251 6.69162 31.2838 6.13545 30.8073C5.85317 30.5662 5.57928 30.3164 5.3134 30.0585C5.2928 30.0387 5.2722 30.0189 5.25198 29.999C2.01145 26.823 0 22.3965 0 17.5C0 7.83487 7.83487 0 17.5 0C27.1651 0 35 7.83487 35 17.5Z" fill="#e5efe8"/>
|
||||||
|
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
|
||||||
|
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
|
||||||
|
<path d="M15.7661 32.1765L16.9463 25.8847C16.9619 25.8019 17.0341 25.7419 17.1183 25.7419H17.7667C17.8518 25.7419 17.9246 25.8032 17.9391 25.8871L19.0289 32.1808C19.0379 32.2323 19.0233 32.285 18.9893 32.3247L17.5726 33.9747C17.504 34.0545 17.3811 34.0563 17.3103 33.9784L15.8086 32.3265C15.7717 32.2859 15.7559 32.2304 15.7661 32.1765Z" fill="#70B62B"/>
|
||||||
|
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
|
||||||
|
<rect x="22.451" y="12.5238" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5238)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_205_498">
|
||||||
|
<rect width="35" height="35" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_4199_10908)">
|
||||||
|
<g filter="url(#filter0_di_4199_10908)">
|
||||||
|
<circle cx="17.5" cy="17.5" r="17.5" fill="#FDFDFD"/>
|
||||||
|
<circle cx="17.5" cy="17.5" r="17" stroke="url(#paint0_linear_4199_10908)" stroke-opacity="0.2"/>
|
||||||
|
</g>
|
||||||
|
<path d="M15.3301 22.9482L15.1152 22.8213C14.4793 22.4435 13.9364 22.0278 13.4297 21.6855L13.4287 21.6846C12.6398 21.1521 12.1076 20.3166 11.9502 19.375L11.6738 17.7227L11.6123 17.3574H11.2422C10.6187 17.3573 10.1152 16.854 10.1152 16.2305C10.1153 15.7671 10.3933 15.37 10.792 15.1973L11.1914 15.0244L11.0205 14.624C10.5503 13.5216 10.2046 12.3526 9.99707 11.1533V11.1523C9.86377 10.3836 9.80431 9.64622 10.002 8.99707C10.2015 8.34043 10.6884 7.89652 11.1309 7.88086L11.1982 7.88672L11.457 7.9043L11.5967 7.68555C11.9829 7.08239 12.5985 6.66833 13.2754 6.42285H13.2764C13.9611 6.17432 14.6919 6.08725 15.4746 5.99707C15.7786 5.96223 16.0789 5.92659 16.3799 5.89648L16.3789 5.89551C17.769 5.76175 19.2371 5.73653 20.5977 6.11523C21.9531 6.4928 23.2258 7.32891 23.8887 8.625C24.4185 9.65968 24.5371 10.937 24.3965 12.1826C24.3036 13.0075 24.1053 13.8182 23.8662 14.6348L23.75 15.0303L24.1377 15.1699C24.5724 15.3256 24.8827 15.741 24.8828 16.2305C24.8828 16.8541 24.38 17.3574 23.7559 17.3574H23.3857L23.3242 17.7227L23.0479 19.375C22.8904 20.3168 22.3584 21.1522 21.5693 21.6846C21.0607 22.0276 20.5195 22.4431 19.8828 22.8213L19.6689 22.9482V24.3721L19.9746 24.4688L25.1543 26.1045C27.0535 26.7041 28.5119 28.1435 29.1777 29.9355C29.1138 29.9956 29.0512 30.0571 28.9863 30.1162L28.5791 30.4756C28.0371 30.9399 27.4652 31.3702 26.8672 31.7637C24.1785 33.5335 20.9593 34.5635 17.499 34.5635C14.0388 34.5635 10.8205 33.5334 8.13184 31.7637H8.13086C7.53293 31.3702 6.96095 30.9399 6.41895 30.4756L6.01172 30.1162C5.94669 30.057 5.88345 29.9958 5.81934 29.9355C6.48477 28.1431 7.94475 26.7039 9.84375 26.1045L15.0234 24.4688L15.3301 24.3721V22.9482Z" fill="#F5F5F5" stroke="black" stroke-width="0.875"/>
|
||||||
|
<path d="M18.1351 24.5H16.7466C16.6261 24.5 16.5416 24.619 16.5814 24.7328L16.9294 25.7282C16.954 25.7984 17.0202 25.8454 17.0946 25.8454H17.7871C17.8615 25.8454 17.9277 25.7984 17.9523 25.7282L18.3003 24.7328C18.3401 24.619 18.2557 24.5 18.1351 24.5Z" fill="#70B62B"/>
|
||||||
|
<path d="M15.7661 32.1768L16.9463 25.8849C16.9619 25.8022 17.0341 25.7422 17.1183 25.7422H17.7667C17.8518 25.7422 17.9246 25.8034 17.9391 25.8873L19.0289 32.1811C19.0379 32.2325 19.0233 32.2853 18.9893 32.3249L17.5726 33.975C17.504 34.0548 17.3811 34.0565 17.3103 33.9787L15.8086 32.3268C15.7717 32.2862 15.7559 32.2307 15.7661 32.1768Z" fill="#70B62B"/>
|
||||||
|
<path d="M23.0874 17.1475C22.6802 17.1566 22.677 17.1565 22.6763 17.2303C22.6751 17.3617 22.5101 17.8603 22.3794 18.1348C22.193 18.5242 21.9528 18.8458 21.6003 19.1728C21.1508 19.5918 20.7029 19.8346 20.1019 19.9893C19.8544 20.0543 19.7678 20.0567 17.9148 20.0492L16.4625 20.0454C16.1964 20.0447 15.9796 20.2589 15.9771 20.525C15.9747 20.7901 16.1885 21.0065 16.4535 21.0072L17.954 21.0114C20.1116 21.0185 20.0763 21.0214 20.6873 20.8219C21.7131 20.4883 22.6568 19.6763 23.1759 18.6776C23.3586 18.3331 23.5398 17.8089 23.6007 17.4664C23.6243 17.3416 23.6479 17.2168 23.6546 17.1848C23.6614 17.1432 23.6455 17.1302 23.5813 17.136C23.5332 17.1388 23.3119 17.1464 23.0874 17.1475Z" fill="black"/>
|
||||||
|
<rect x="22.451" y="12.5248" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 22.451 12.5248)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<rect x="9.45097" y="12.4037" width="3.2" height="5.2" rx="0.6" transform="rotate(0.530608 9.45097 12.4037)" fill="#F5F5F5" stroke="black" stroke-width="0.8"/>
|
||||||
|
<path d="M15.293 20.4473C15.293 19.895 15.7407 19.4473 16.293 19.4473H17.293C17.8453 19.4473 18.293 19.895 18.293 20.4473C18.293 20.9996 17.8453 21.4473 17.293 21.4473H16.293C15.7407 21.4473 15.293 20.9996 15.293 20.4473Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_di_4199_10908" x="-2" y="-1" width="39" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="1"/>
|
||||||
|
<feGaussianBlur stdDeviation="1"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="out"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 0 0.901961 0 0 0 1 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4199_10908"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4199_10908" result="shape"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="-0.25"/>
|
||||||
|
<feGaussianBlur stdDeviation="1.5"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.4 0"/>
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_4199_10908"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_4199_10908" x1="1.09375" y1="1.59091" x2="35" y2="1.59091" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C0FF7B"/>
|
||||||
|
<stop offset="0.65625" stop-color="#70B62B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_4199_10908">
|
||||||
|
<rect width="35" height="35" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 7.5H19.5341" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 12.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 17.5H19.5" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21.0755 4.16406L3.39658 4.16406C2.71318 4.16406 2.15918 4.71777 2.15918 5.4008L2.15918 19.0967C2.15918 19.7797 2.71318 20.3335 3.39658 20.3335H21.0755C21.7589 20.3335 22.3129 19.7797 22.3129 19.0967V5.4008C22.3129 4.71777 21.7589 4.16406 21.0755 4.16406Z" stroke="#808080" stroke-width="1.15" stroke-miterlimit="10"/>
|
||||||
|
<path d="M9.10286 12.375L2.60676 18.15C1.74915 18.9153 2.28822 20.3357 3.43986 20.3357H21.033C22.1846 20.3357 22.7237 18.9153 21.8661 18.15L15.37 12.375" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.6101 14.5783L21.8661 6.34978C22.7237 5.58447 22.1846 4.16406 21.033 4.16406L3.43986 4.16406C2.28822 4.16406 1.74915 5.58447 2.60676 6.34978L11.8628 14.5783C12.0772 14.7681 12.4018 14.7681 12.6101 14.5783Z" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_mobile_spreed)">
|
||||||
|
<path d="M11.1875 19.743V18.7995C11.1875 18.3126 11.5822 17.918 12.069 17.918H13.3336C13.8204 17.918 14.2151 18.3126 14.2151 18.7995V19.743C14.2151 20.2298 13.8204 20.6245 13.3336 20.6245H12.069C11.5822 20.6245 11.1875 20.2298 11.1875 19.743Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
<path d="M7.09047 15.9141C8.56573 17.3894 10.4773 18.1623 12.4098 18.2329C14.5365 18.3106 16.6884 17.5376 18.3119 15.9141C21.4106 12.8154 21.4106 7.7915 18.3119 4.69274C15.2131 1.59398 10.1892 1.59398 7.09047 4.69274C3.99171 7.7915 3.99171 12.8154 7.09047 15.9141Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.1764 12.8321C10.8403 13.496 11.7005 13.8438 12.5701 13.8755C13.527 13.9105 14.4954 13.5627 15.2259 12.8321C16.6203 11.4377 16.6203 9.17699 15.2259 7.78258C13.8315 6.38816 11.5708 6.38816 10.1764 7.78258C8.782 9.17699 8.782 11.4377 10.1764 12.8321Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.1779 5.30035C12.3155 5.43795 12.4938 5.51003 12.674 5.51662C12.8723 5.52386 13.073 5.45177 13.2245 5.30035C13.5135 5.01134 13.5135 4.54278 13.2245 4.25376C12.9355 3.96475 12.4669 3.96475 12.1779 4.25376C11.8889 4.54278 11.8889 5.01134 12.1779 5.30035Z" fill="#808080"/>
|
||||||
|
<path d="M7.47754 22.1609V21.2174C7.47754 20.7306 7.8722 20.3359 8.35905 20.3359H17.0433C17.5302 20.3359 17.9248 20.7306 17.9248 21.2174V22.1609C17.9248 22.6478 17.5302 23.0425 17.0433 23.0425H8.35905C7.87221 23.0425 7.47754 22.6478 7.47754 22.1609Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_mobile_spreed">
|
||||||
|
<rect width="22.0417" height="23" fill="white" transform="translate(1.47949 1)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 20.0191V4.98038C3 3.88665 3.88665 3 4.98038 3H20.0196C21.1133 3 22 3.88665 22 4.98038V20.0191C22 21.1131 21.1131 22 20.0191 22H4.98087C3.88687 22 3 21.1131 3 20.0191Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
<path d="M3 8.48077V4.98087C3 3.88687 3.88687 3 4.98087 3H20.0191C21.1131 3 22 3.88687 22 4.98087V8.48077H3Z" fill="#FBFBFB" stroke="#808080" stroke-width="1.15"/>
|
||||||
|
<mask id="path-3-inside-1_mobile_tasks" fill="white">
|
||||||
|
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769"/>
|
||||||
|
</mask>
|
||||||
|
<rect x="5" y="10.6211" width="9.20801" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-3-inside-1_mobile_tasks)"/>
|
||||||
|
<mask id="path-4-inside-2_mobile_tasks" fill="white">
|
||||||
|
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769"/>
|
||||||
|
</mask>
|
||||||
|
<rect x="7" y="15.6211" width="13" height="4" rx="0.730769" stroke="#808080" stroke-width="2.3" mask="url(#path-4-inside-2_mobile_tasks)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.9847 20L7.43923 13.1999C7.01817 12.8204 7.01861 12.1598 7.44019 11.7809L14.9847 5" stroke="#808080" stroke-width="1.73077" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
@@ -236,31 +236,34 @@ private fun F7AppMenuGridItem(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
val localIcon = item.localIcon
|
// Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
|
||||||
if (localIcon != null) {
|
// как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
|
||||||
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
|
val frameShape = CircleShape
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(MenuIconSize)
|
.size(MenuIconSize)
|
||||||
.clip(CircleShape)
|
.shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
|
||||||
.background(Color.White)
|
.clip(frameShape)
|
||||||
.border(1.5.dp, F7Colors.Green30, CircleShape),
|
.background(Color.White)
|
||||||
contentAlignment = Alignment.Center,
|
.border(1.dp, Color(0xFFECECEC), frameShape),
|
||||||
) {
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val localIcon = item.localIcon
|
||||||
|
if (localIcon != null) {
|
||||||
Icon(
|
Icon(
|
||||||
localIcon,
|
localIcon,
|
||||||
contentDescription = item.label,
|
contentDescription = item.label,
|
||||||
tint = F7Colors.Primary,
|
tint = F7Colors.Primary,
|
||||||
modifier = Modifier.size(MenuIconSize * 0.46f),
|
modifier = Modifier.size(MenuIconSize * 0.5f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.iconUrl,
|
||||||
|
contentDescription = item.label,
|
||||||
|
modifier = Modifier.size(MenuIconSize * 0.56f),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
AsyncImage(
|
|
||||||
model = item.iconUrl,
|
|
||||||
contentDescription = item.label,
|
|
||||||
modifier = Modifier.size(MenuIconSize),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ enum class F7BottomBarSlot {
|
|||||||
Chats,
|
Chats,
|
||||||
NavBack,
|
NavBack,
|
||||||
SectionMail,
|
SectionMail,
|
||||||
SectionCards,
|
SectionTasks,
|
||||||
SectionConferences,
|
SectionConferences,
|
||||||
Menu,
|
Menu,
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,8 @@ data class F7BottomBarConfig(
|
|||||||
val buttonCount: Int get() = slots.size
|
val buttonCount: Int get() = slots.size
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// Единая навигационная панель по дизайну «Новое меню»:
|
// Нижняя панель 1:1 с мобильным сайтом forbion (layout.user.php):
|
||||||
// слева «назад», затем ярлыки-разделы Почта · Карточки · Конференции,
|
// «назад», затем ярлыки-разделы Почта · Задачи · Конференции, справа — меню.
|
||||||
// справа — выдвижное меню. Кнопки «чаты» (по просьбе владельца убрана),
|
|
||||||
// профиль/уведомления/настройки/создать в панели нет (меню и шапки экранов).
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun forContext(
|
fun forContext(
|
||||||
tabKey: String,
|
tabKey: String,
|
||||||
@@ -27,7 +25,7 @@ data class F7BottomBarConfig(
|
|||||||
listOf(
|
listOf(
|
||||||
F7BottomBarSlot.NavBack,
|
F7BottomBarSlot.NavBack,
|
||||||
F7BottomBarSlot.SectionMail,
|
F7BottomBarSlot.SectionMail,
|
||||||
F7BottomBarSlot.SectionCards,
|
F7BottomBarSlot.SectionTasks,
|
||||||
F7BottomBarSlot.SectionConferences,
|
F7BottomBarSlot.SectionConferences,
|
||||||
F7BottomBarSlot.Menu,
|
F7BottomBarSlot.Menu,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,19 +30,24 @@ data class F7BottomBarActions(
|
|||||||
val onChatsClick: () -> Unit = {},
|
val onChatsClick: () -> Unit = {},
|
||||||
val onNavBackClick: () -> Unit = {},
|
val onNavBackClick: () -> Unit = {},
|
||||||
val onMailClick: () -> Unit = {},
|
val onMailClick: () -> Unit = {},
|
||||||
val onCardsClick: () -> Unit = {},
|
val onTasksClick: () -> Unit = {},
|
||||||
val onConferencesClick: () -> Unit = {},
|
val onConferencesClick: () -> Unit = {},
|
||||||
val onMenuClick: () -> Unit = {},
|
val onMenuClick: () -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Иконки нижней панели — ЛОКАЛЬНЫЕ mobile-*-icon из живой темы (assets/menu/), 1:1 с сайтом.
|
||||||
|
private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
|
||||||
|
|
||||||
// Живой forbion (_header.css .header__mobile-bottom): пилюля 328×61, паддинг 3,
|
// Живой forbion (_header.css .header__mobile-bottom): пилюля 328×61, паддинг 3,
|
||||||
// фон #F5F5F5; кнопки 55, фон #FBFBFB, рамка #E6E6E6; активная кнопка-раздел и открытый
|
// фон #F5F5F5; кнопки 55, фон #FBFBFB, рамка #E6E6E6; активная кнопка-раздел и открытый
|
||||||
// бургер — полупрозрачный зелёный градиент 104°.
|
// бургер — полупрозрачный зелёный градиент 104°.
|
||||||
private val BottomBarWidth = 328.dp
|
private val BottomBarWidth = 328.dp
|
||||||
private val BottomBarHeight = 61.dp
|
private val BottomBarHeight = 61.dp
|
||||||
private val BottomBarButtonSize = 55.dp
|
private val BottomBarButtonSize = 55.dp
|
||||||
private val BottomBarIconSize = 34.dp
|
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
|
||||||
private val BottomBarSectionIconSize = 46.dp
|
private val BottomBarIconSize = 25.dp
|
||||||
|
private val BottomBarSectionIconSize = 25.dp
|
||||||
|
private val BottomBarBackIconSize = 18.dp
|
||||||
private val BottomBarOuterPadding = 3.dp
|
private val BottomBarOuterPadding = 3.dp
|
||||||
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
||||||
private val BottomBarButtonBg = Color(0xFFFBFBFB)
|
private val BottomBarButtonBg = Color(0xFFFBFBFB)
|
||||||
@@ -94,38 +99,35 @@ fun F7MobileBottomBar(
|
|||||||
onClick = actions.onChatsClick,
|
onClick = actions.onChatsClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
|
iconUrl = barIcon("sidebar-chevron-left.svg"),
|
||||||
contentDescription = "Папки",
|
contentDescription = "Назад",
|
||||||
|
iconSize = BottomBarBackIconSize,
|
||||||
iconRotation = if (navBackHighlighted) 180f else 0f,
|
iconRotation = if (navBackHighlighted) 180f else 0f,
|
||||||
onClick = actions.onNavBackClick,
|
onClick = actions.onNavBackClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionMail -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/mail-header-icon.svg",
|
iconUrl = barIcon("mobile-mail-icon.svg"),
|
||||||
contentDescription = "Почта",
|
contentDescription = "Почта",
|
||||||
iconSize = BottomBarSectionIconSize,
|
iconSize = BottomBarSectionIconSize,
|
||||||
active = activeTabKey.equals("mail", ignoreCase = true),
|
active = activeTabKey.equals("mail", ignoreCase = true),
|
||||||
onClick = actions.onMailClick,
|
onClick = actions.onMailClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.SectionCards -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionTasks -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/deck-header-icon.svg",
|
iconUrl = barIcon("mobile-tasks-icon.svg"),
|
||||||
contentDescription = "Карточки",
|
contentDescription = "Задачи",
|
||||||
iconSize = BottomBarSectionIconSize,
|
iconSize = BottomBarSectionIconSize,
|
||||||
active = activeTabKey.equals("deck", ignoreCase = true),
|
active = activeTabKey.equals("tasks", ignoreCase = true),
|
||||||
onClick = actions.onCardsClick,
|
onClick = actions.onTasksClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
|
F7BottomBarSlot.SectionConferences -> F7BottomBarIconSlot(
|
||||||
iconUrl = "$base/themes/forbion/images/header/spreed-header-icon.svg",
|
iconUrl = barIcon("mobile-spreed-icon.svg"),
|
||||||
contentDescription = "Конференции",
|
contentDescription = "Конференции",
|
||||||
iconSize = BottomBarSectionIconSize,
|
iconSize = BottomBarSectionIconSize,
|
||||||
active = activeTabKey.equals("talk", ignoreCase = true),
|
active = activeTabKey.equals("talk", ignoreCase = true),
|
||||||
onClick = actions.onConferencesClick,
|
onClick = actions.onConferencesClick,
|
||||||
)
|
)
|
||||||
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
|
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
|
||||||
iconUrl = if (menuOpen) {
|
iconUrl = barIcon("mobile-burger-icon.svg"),
|
||||||
"$base/themes/forbion/images/header/menu-burger-green.svg"
|
|
||||||
} else {
|
|
||||||
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
|
||||||
},
|
|
||||||
contentDescription = "Меню",
|
contentDescription = "Меню",
|
||||||
active = menuOpen,
|
active = menuOpen,
|
||||||
showBadge = showNotificationBadge,
|
showBadge = showNotificationBadge,
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ data class DavCalendar(
|
|||||||
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||||
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||||
val ctag: String = "",
|
val ctag: String = "",
|
||||||
|
// supported-calendar-component-set: поддерживает ли коллекция VEVENT. VTODO-only списки
|
||||||
|
// задач не принимают события → PUT VEVENT туда возвращает 403. По умолчанию true (если
|
||||||
|
// сервер не вернул свойство — не ломаем поведение).
|
||||||
|
val supportsEvents: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DavEvent(
|
data class DavEvent(
|
||||||
@@ -50,6 +54,7 @@ data class DavEvent(
|
|||||||
val organizerName: String = "",
|
val organizerName: String = "",
|
||||||
val alarms: List<CalendarAlarmData> = emptyList(),
|
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||||
val conferenceUri: String = "",
|
val conferenceUri: String = "",
|
||||||
|
val attachments: List<CalendarAttachmentData> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DavTrashEvent(
|
data class DavTrashEvent(
|
||||||
@@ -75,6 +80,8 @@ data class DavTask(
|
|||||||
val description: String,
|
val description: String,
|
||||||
val calendarName: String,
|
val calendarName: String,
|
||||||
val calendarHref: String,
|
val calendarHref: String,
|
||||||
|
/** UID родительской задачи (RELATED-TO;RELTYPE=PARENT) — для подзадач; пусто у корневых. */
|
||||||
|
val parentUid: String = "",
|
||||||
) {
|
) {
|
||||||
val isCompleted: Boolean
|
val isCompleted: Boolean
|
||||||
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
|
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
|
||||||
@@ -87,6 +94,11 @@ object CalDavClient {
|
|||||||
private val icsDue = Pattern.compile("DUE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
private val icsDue = Pattern.compile("DUE[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
private val icsStatus = Pattern.compile("STATUS:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
private val icsStatus = Pattern.compile("STATUS:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
private val icsUid = Pattern.compile("UID:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
private val icsUid = Pattern.compile("UID:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
|
// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.
|
||||||
|
private val icsRelatedTo = Pattern.compile(
|
||||||
|
"RELATED-TO(?:;[^:\\r\\n]*)?:([^\\r\\n]+)",
|
||||||
|
Pattern.CASE_INSENSITIVE,
|
||||||
|
)
|
||||||
private val icsDtStamp = Pattern.compile("DTSTAMP[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
private val icsDtStamp = Pattern.compile("DTSTAMP[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
private val icsLastModified = Pattern.compile("LAST-MODIFIED[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
private val icsLastModified = Pattern.compile("LAST-MODIFIED[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
private val icsPriority = Pattern.compile("PRIORITY:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
private val icsPriority = Pattern.compile("PRIORITY:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)
|
||||||
@@ -105,8 +117,8 @@ object CalDavClient {
|
|||||||
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
|
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
|
||||||
val body = """
|
val body = """
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
|
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
|
||||||
</d:propfind>
|
</d:propfind>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||||
@@ -291,11 +303,27 @@ object CalDavClient {
|
|||||||
}
|
}
|
||||||
client.newCall(builder.build()).execute().use { response ->
|
client.newCall(builder.build()).execute().use { response ->
|
||||||
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
|
||||||
error("CalDAV put event HTTP ${response.code}")
|
error("CalDAV put event HTTP ${response.code}${davErrorDetail(response)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Из тела sabre-ошибки достаём человекочитаемую причину (precondition/сообщение). */
|
||||||
|
private fun davErrorDetail(response: okhttp3.Response): String {
|
||||||
|
val body = runCatching { response.peekBody(4096).string() }.getOrNull().orEmpty()
|
||||||
|
if (body.isBlank()) return ""
|
||||||
|
// sabre: <s:message>…</s:message> и имя нарушенного precondition-элемента.
|
||||||
|
val message = Regex("<s:message>(.*?)</s:message>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(body)?.groupValues?.getOrNull(1)?.trim()
|
||||||
|
val precondition = Regex("<cal:(supported-calendar-component-set|valid-calendar-object-resource|calendar-collection-location-ok)")
|
||||||
|
.find(body)?.groupValues?.getOrNull(1)
|
||||||
|
?: Regex("<d:(need-privileges)").find(body)?.groupValues?.getOrNull(1)
|
||||||
|
return buildString {
|
||||||
|
if (precondition != null) append(" ($precondition)")
|
||||||
|
if (!message.isNullOrBlank()) append(": ${message.take(180)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createCalendar(
|
fun createCalendar(
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
@@ -332,6 +360,122 @@ object CalDavClient {
|
|||||||
return DavCalendar(href = url, displayName = displayName)
|
return DavCalendar(href = url, displayName = displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PROPPATCH: переименование и/или смена цвета календаря (null = не менять). */
|
||||||
|
fun updateCalendarProps(
|
||||||
|
client: OkHttpClient,
|
||||||
|
calendarHref: String,
|
||||||
|
displayName: String? = null,
|
||||||
|
color: String? = null,
|
||||||
|
) {
|
||||||
|
if (displayName == null && color == null) return
|
||||||
|
val props = buildString {
|
||||||
|
if (displayName != null) append("<d:displayname>${escapeXml(displayName)}</d:displayname>")
|
||||||
|
if (color != null) append("<ical:calendar-color>${escapeXml(color)}</ical:calendar-color>")
|
||||||
|
}
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propertyupdate xmlns:d="DAV:" xmlns:ical="http://apple.com/ns/ical/">
|
||||||
|
<d:set><d:prop>$props</d:prop></d:set>
|
||||||
|
</d:propertyupdate>
|
||||||
|
""".trimIndent()
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(calendarHref.trimEnd('/') + "/")
|
||||||
|
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||||
|
.build()
|
||||||
|
client.newCall(req).execute().use { response ->
|
||||||
|
if (response.code !in 200..299) error("CalDAV proppatch HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCalendar(client: OkHttpClient, calendarHref: String) {
|
||||||
|
val req = Request.Builder().url(calendarHref.trimEnd('/') + "/").delete().build()
|
||||||
|
client.newCall(req).execute().use { response ->
|
||||||
|
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
|
||||||
|
error("CalDAV delete calendar HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DavSharee(
|
||||||
|
val principal: String, // principal:principals/users/<id> или groups/<id>
|
||||||
|
val displayName: String,
|
||||||
|
val writable: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Текущие получатели шаринга календаря (oc:invite). */
|
||||||
|
fun listCalendarShares(client: OkHttpClient, calendarHref: String): List<DavSharee> {
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:prop><oc:invite/></d:prop>
|
||||||
|
</d:propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
val xml = propfind(client, calendarHref.trimEnd('/') + "/", depth = 0, body)
|
||||||
|
val out = mutableListOf<DavSharee>()
|
||||||
|
val parser = newParser(xml)
|
||||||
|
var principal = ""
|
||||||
|
var name = ""
|
||||||
|
var writable = false
|
||||||
|
var inUser = false
|
||||||
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (parser.eventType) {
|
||||||
|
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||||
|
"user" -> { inUser = true; principal = ""; name = ""; writable = false }
|
||||||
|
"href" -> if (inUser) principal = parser.readText().trim()
|
||||||
|
"common-name" -> if (inUser) name = parser.readText().trim()
|
||||||
|
"read-write" -> if (inUser) writable = true
|
||||||
|
}
|
||||||
|
XmlPullParser.END_TAG -> if (parser.localTag() == "user" && inUser) {
|
||||||
|
if (principal.isNotBlank()) {
|
||||||
|
out += DavSharee(
|
||||||
|
principal = principal,
|
||||||
|
displayName = name.ifBlank { principal.substringAfterLast('/') },
|
||||||
|
writable = writable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inUser = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пошарить календарь пользователю/группе (sabre sharing POST). */
|
||||||
|
fun shareCalendar(client: OkHttpClient, calendarHref: String, principal: String, writable: Boolean) {
|
||||||
|
val rw = if (writable) "<o:read-write/>" else ""
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
|
||||||
|
<o:set>
|
||||||
|
<d:href>${escapeXml(principal)}</d:href>
|
||||||
|
<o:summary/>$rw
|
||||||
|
</o:set>
|
||||||
|
</o:share>
|
||||||
|
""".trimIndent()
|
||||||
|
sharePost(client, calendarHref, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unshareCalendar(client: OkHttpClient, calendarHref: String, principal: String) {
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
|
||||||
|
<o:remove><d:href>${escapeXml(principal)}</d:href></o:remove>
|
||||||
|
</o:share>
|
||||||
|
""".trimIndent()
|
||||||
|
sharePost(client, calendarHref, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sharePost(client: OkHttpClient, calendarHref: String, body: String) {
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(calendarHref.trimEnd('/') + "/")
|
||||||
|
.post(body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
|
||||||
|
.build()
|
||||||
|
client.newCall(req).execute().use { response ->
|
||||||
|
if (response.code !in 200..299) error("CalDAV share HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createSubscription(
|
fun createSubscription(
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
@@ -586,6 +730,7 @@ object CalDavClient {
|
|||||||
organizerName = parsed.organizerName,
|
organizerName = parsed.organizerName,
|
||||||
alarms = parsed.alarms,
|
alarms = parsed.alarms,
|
||||||
conferenceUri = parsed.conferenceUri,
|
conferenceUri = parsed.conferenceUri,
|
||||||
|
attachments = parsed.attachments,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +779,7 @@ object CalDavClient {
|
|||||||
dueDate: LocalDate? = null,
|
dueDate: LocalDate? = null,
|
||||||
priority: Int = 0,
|
priority: Int = 0,
|
||||||
description: String = "",
|
description: String = "",
|
||||||
|
parentUid: String = "",
|
||||||
): DavTask {
|
): DavTask {
|
||||||
val uid = "${UUID.randomUUID()}@f7cloud.mobile"
|
val uid = "${UUID.randomUUID()}@f7cloud.mobile"
|
||||||
val ics = buildVTodoIcs(
|
val ics = buildVTodoIcs(
|
||||||
@@ -644,6 +790,7 @@ object CalDavClient {
|
|||||||
priority = priority,
|
priority = priority,
|
||||||
percentComplete = 0,
|
percentComplete = 0,
|
||||||
description = description,
|
description = description,
|
||||||
|
parentUid = parentUid,
|
||||||
)
|
)
|
||||||
val url = calendar.href.trimEnd('/') + "/$uid.ics"
|
val url = calendar.href.trimEnd('/') + "/$uid.ics"
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
@@ -670,6 +817,7 @@ object CalDavClient {
|
|||||||
description = description,
|
description = description,
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName,
|
||||||
calendarHref = calendar.href,
|
calendarHref = calendar.href,
|
||||||
|
parentUid = parentUid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +839,7 @@ object CalDavClient {
|
|||||||
priority = priority,
|
priority = priority,
|
||||||
percentComplete = percentComplete,
|
percentComplete = percentComplete,
|
||||||
description = description,
|
description = description,
|
||||||
|
parentUid = task.parentUid, // не терять связь с родителем при правке подзадачи
|
||||||
)
|
)
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url(task.href)
|
.url(task.href)
|
||||||
@@ -788,6 +937,99 @@ object CalDavClient {
|
|||||||
.format(instant)
|
.format(instant)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Занятый интервал участника (free/busy), millis UTC. */
|
||||||
|
data class BusyPeriod(val startEpochMilli: Long, val endEpochMilli: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос занятости участников через scheduling outbox (iTIP VFREEBUSY REQUEST).
|
||||||
|
* Возвращает email → занятые интервалы; участник без данных — пустой список.
|
||||||
|
*/
|
||||||
|
fun freeBusy(
|
||||||
|
client: OkHttpClient,
|
||||||
|
serverUrl: String,
|
||||||
|
userId: String,
|
||||||
|
organizerEmail: String,
|
||||||
|
attendeeEmails: List<String>,
|
||||||
|
rangeStart: Instant,
|
||||||
|
rangeEnd: Instant,
|
||||||
|
): Map<String, List<BusyPeriod>> {
|
||||||
|
if (organizerEmail.isBlank() || attendeeEmails.isEmpty()) return emptyMap()
|
||||||
|
val outbox = calendarsBase(serverUrl, userId).trimEnd('/') + "/outbox/"
|
||||||
|
val ics = buildString {
|
||||||
|
appendLine("BEGIN:VCALENDAR")
|
||||||
|
appendLine("VERSION:2.0")
|
||||||
|
appendLine("PRODID:-//F7cloud Mobile//EN")
|
||||||
|
appendLine("METHOD:REQUEST")
|
||||||
|
appendLine("BEGIN:VFREEBUSY")
|
||||||
|
appendLine("UID:${UUID.randomUUID()}@f7cloud.mobile")
|
||||||
|
appendLine("DTSTAMP:${formatCalDavTime(Instant.now())}")
|
||||||
|
appendLine("DTSTART:${formatCalDavTime(rangeStart)}")
|
||||||
|
appendLine("DTEND:${formatCalDavTime(rangeEnd)}")
|
||||||
|
appendLine("ORGANIZER:mailto:$organizerEmail")
|
||||||
|
attendeeEmails.forEach { appendLine("ATTENDEE:mailto:$it") }
|
||||||
|
appendLine("END:VFREEBUSY")
|
||||||
|
appendLine("END:VCALENDAR")
|
||||||
|
}
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url(outbox)
|
||||||
|
.post(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
|
||||||
|
.build()
|
||||||
|
val xml = client.newCall(req).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) error("CalDAV free-busy HTTP ${response.code}")
|
||||||
|
response.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
return parseFreeBusyResponse(xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>> {
|
||||||
|
val out = mutableMapOf<String, List<BusyPeriod>>()
|
||||||
|
val parser = newParser(xml)
|
||||||
|
var recipient = ""
|
||||||
|
var calendarData = StringBuilder()
|
||||||
|
var inCalendarData = false
|
||||||
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
|
when (parser.eventType) {
|
||||||
|
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||||
|
"response" -> { recipient = ""; calendarData = StringBuilder() }
|
||||||
|
"href" -> if (recipient.isBlank()) recipient = parser.readText().trim()
|
||||||
|
"calendar-data" -> { inCalendarData = true; calendarData = StringBuilder() }
|
||||||
|
}
|
||||||
|
XmlPullParser.TEXT -> if (inCalendarData) calendarData.append(parser.text)
|
||||||
|
XmlPullParser.END_TAG -> when (parser.localTag()) {
|
||||||
|
"calendar-data" -> inCalendarData = false
|
||||||
|
"response" -> if (recipient.isNotBlank()) {
|
||||||
|
val email = recipient.removePrefix("mailto:").trim()
|
||||||
|
out[email] = parseBusyPeriods(calendarData.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private val freeBusyLine = Pattern.compile("^FREEBUSY[^:]*:(.+)$", Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
|
||||||
|
|
||||||
|
private fun parseBusyPeriods(ics: String): List<BusyPeriod> {
|
||||||
|
val unfolded = ics.replace(Regex("\\r?\\n[ \\t]"), "")
|
||||||
|
val out = mutableListOf<BusyPeriod>()
|
||||||
|
val m = freeBusyLine.matcher(unfolded)
|
||||||
|
while (m.find()) {
|
||||||
|
m.group(1).orEmpty().split(',').forEach { period ->
|
||||||
|
val parts = period.trim().split('/')
|
||||||
|
if (parts.size != 2) return@forEach
|
||||||
|
val start = CalendarIcs.parseIcsInstant(parts[0]) ?: return@forEach
|
||||||
|
val end = if (parts[1].startsWith("P", ignoreCase = true)) {
|
||||||
|
runCatching { start.plus(java.time.Duration.parse(parts[1])) }.getOrNull() ?: return@forEach
|
||||||
|
} else {
|
||||||
|
CalendarIcs.parseIcsInstant(parts[1]) ?: return@forEach
|
||||||
|
}
|
||||||
|
out += BusyPeriod(start.toEpochMilli(), end.toEpochMilli())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sortedBy { it.startEpochMilli }
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> {
|
private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> {
|
||||||
val parser = newParser(xml)
|
val parser = newParser(xml)
|
||||||
val out = mutableListOf<DavCalendar>()
|
val out = mutableListOf<DavCalendar>()
|
||||||
@@ -798,6 +1040,7 @@ object CalDavClient {
|
|||||||
var calendarColor: String? = null
|
var calendarColor: String? = null
|
||||||
var ctag = ""
|
var ctag = ""
|
||||||
var isCollection = false
|
var isCollection = false
|
||||||
|
var comps = mutableSetOf<String>()
|
||||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
when (parser.eventType) {
|
when (parser.eventType) {
|
||||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||||
@@ -808,8 +1051,12 @@ object CalDavClient {
|
|||||||
calendarColor = null
|
calendarColor = null
|
||||||
ctag = ""
|
ctag = ""
|
||||||
isCollection = false
|
isCollection = false
|
||||||
|
comps = mutableSetOf()
|
||||||
}
|
}
|
||||||
"collection" -> if (inResponse) isCollection = true
|
"collection" -> if (inResponse) isCollection = true
|
||||||
|
"comp" -> if (inResponse) {
|
||||||
|
parser.getAttributeValue(null, "name")?.trim()?.uppercase()?.let { comps += it }
|
||||||
|
}
|
||||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||||
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
||||||
"getctag" -> if (inResponse) ctag = parser.readText().trim()
|
"getctag" -> if (inResponse) ctag = parser.readText().trim()
|
||||||
@@ -826,7 +1073,15 @@ object CalDavClient {
|
|||||||
val name = displayName.ifBlank {
|
val name = displayName.ifBlank {
|
||||||
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
||||||
}
|
}
|
||||||
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
|
// Свойство не вернулось (comps пуст) → считаем событийным (не ломаем).
|
||||||
|
val supportsEvents = comps.isEmpty() || "VEVENT" in comps
|
||||||
|
out += DavCalendar(
|
||||||
|
href = full,
|
||||||
|
displayName = name,
|
||||||
|
color = calendarColor,
|
||||||
|
ctag = ctag,
|
||||||
|
supportsEvents = supportsEvents,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inResponse = false
|
inResponse = false
|
||||||
@@ -938,6 +1193,7 @@ object CalDavClient {
|
|||||||
description = extractIcs(icsDescription, ics),
|
description = extractIcs(icsDescription, ics),
|
||||||
calendarName = calendarName,
|
calendarName = calendarName,
|
||||||
calendarHref = calendarHref,
|
calendarHref = calendarHref,
|
||||||
|
parentUid = extractIcs(icsRelatedTo, ics).trim(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,6 +1205,7 @@ object CalDavClient {
|
|||||||
priority: Int,
|
priority: Int,
|
||||||
percentComplete: Int,
|
percentComplete: Int,
|
||||||
description: String,
|
description: String,
|
||||||
|
parentUid: String = "",
|
||||||
): String {
|
): String {
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
return buildString {
|
return buildString {
|
||||||
@@ -976,6 +1233,9 @@ object CalDavClient {
|
|||||||
if (description.isNotBlank()) {
|
if (description.isNotBlank()) {
|
||||||
appendLine("DESCRIPTION:${escapeIcsText(description)}")
|
appendLine("DESCRIPTION:${escapeIcsText(description)}")
|
||||||
}
|
}
|
||||||
|
if (parentUid.isNotBlank()) {
|
||||||
|
appendLine("RELATED-TO;RELTYPE=PARENT:${escapeIcsText(parentUid)}")
|
||||||
|
}
|
||||||
appendLine("END:VTODO")
|
appendLine("END:VTODO")
|
||||||
appendLine("END:VCALENDAR")
|
appendLine("END:VCALENDAR")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ data class CalendarAlarmData(
|
|||||||
val action: String = "DISPLAY",
|
val action: String = "DISPLAY",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CalendarAttachmentData(
|
||||||
|
val url: String,
|
||||||
|
val fileName: String = "",
|
||||||
|
val mimeType: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
data class CalendarEventData(
|
data class CalendarEventData(
|
||||||
val uid: String,
|
val uid: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
@@ -40,6 +46,7 @@ data class CalendarEventData(
|
|||||||
val organizerName: String = "",
|
val organizerName: String = "",
|
||||||
val alarms: List<CalendarAlarmData> = emptyList(),
|
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||||
val conferenceUri: String = "",
|
val conferenceUri: String = "",
|
||||||
|
val attachments: List<CalendarAttachmentData> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
object CalendarIcs {
|
object CalendarIcs {
|
||||||
@@ -99,6 +106,13 @@ object CalendarIcs {
|
|||||||
val talkUrl = conference.ifBlank {
|
val talkUrl = conference.ifBlank {
|
||||||
if (location.contains("/call/")) location else ""
|
if (location.contains("/call/")) location else ""
|
||||||
}
|
}
|
||||||
|
val attachments = props.filter { it.name == "ATTACH" && it.value.isNotBlank() }.map {
|
||||||
|
CalendarAttachmentData(
|
||||||
|
url = it.value.trim(),
|
||||||
|
fileName = it.params["FILENAME"]?.let(::unescape).orEmpty(),
|
||||||
|
mimeType = it.params["FMTTYPE"].orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
return CalendarEventData(
|
return CalendarEventData(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
|
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
|
||||||
@@ -116,6 +130,7 @@ object CalendarIcs {
|
|||||||
organizerName = orgName,
|
organizerName = orgName,
|
||||||
alarms = alarms,
|
alarms = alarms,
|
||||||
conferenceUri = talkUrl,
|
conferenceUri = talkUrl,
|
||||||
|
attachments = attachments,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +178,13 @@ object CalendarIcs {
|
|||||||
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
|
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
|
||||||
if (data.location.isBlank()) appendLine("LOCATION:$talk")
|
if (data.location.isBlank()) appendLine("LOCATION:$talk")
|
||||||
}
|
}
|
||||||
|
data.attachments.forEach { att ->
|
||||||
|
if (att.url.isNotBlank()) {
|
||||||
|
val fmt = if (att.mimeType.isNotBlank()) ";FMTTYPE=${att.mimeType}" else ""
|
||||||
|
val fname = if (att.fileName.isNotBlank()) ";FILENAME=${escape(att.fileName)}" else ""
|
||||||
|
appendLine("ATTACH$fmt$fname:${att.url}")
|
||||||
|
}
|
||||||
|
}
|
||||||
data.alarms.forEach { alarm ->
|
data.alarms.forEach { alarm ->
|
||||||
appendLine("BEGIN:VALARM")
|
appendLine("BEGIN:VALARM")
|
||||||
appendLine("ACTION:${alarm.action}")
|
appendLine("ACTION:${alarm.action}")
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ object NetworkFactory {
|
|||||||
clients.clear() // пересобрать производные клиенты уже с кэшем
|
clients.clear() // пересобрать производные клиенты уже с кэшем
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Билдер на основе базового клиента (пул/кэш) c опциональным trust-all TLS, без auth. */
|
||||||
|
fun newBaseBuilder(trustAllCerts: Boolean = false): OkHttpClient.Builder =
|
||||||
|
base.newBuilder()
|
||||||
|
.callTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.applyUnsafeSslIfNeeded(trustAllCerts)
|
||||||
|
|
||||||
fun newAuthedClient(
|
fun newAuthedClient(
|
||||||
username: String,
|
username: String,
|
||||||
appPassword: String,
|
appPassword: String,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливает НАСТОЯЩУЮ веб-сессию Nextcloud (cookie + CSRF) по форме `/login`, используя
|
||||||
|
* app-пароль как пароль (он валиден для входа и обходит 2FA). Нужно для встраивания веб-viewer'ов
|
||||||
|
* (доска Whiteboard и т.п.), которым мало Basic-auth: их Vue-XHR ходят по сессии.
|
||||||
|
*/
|
||||||
|
object NextcloudWebSession {
|
||||||
|
private val requestTokenRegex = Regex(
|
||||||
|
"""(?:name=["']requesttoken["']\s+value|<meta\s+name=["']requesttoken["']\s+content)=["']([^"']+)["']""",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Cookie, собранные при логине (для переноса в WebView CookieManager). */
|
||||||
|
fun login(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
appPassword: String,
|
||||||
|
trustAllCerts: Boolean = false,
|
||||||
|
): List<Cookie> {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
val host = base.toHttpUrl().host
|
||||||
|
val jar = MemoryCookieJar()
|
||||||
|
val client = NetworkFactory.newBaseBuilder(trustAllCerts)
|
||||||
|
.cookieJar(jar)
|
||||||
|
.followRedirects(true)
|
||||||
|
.followSslRedirects(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// 1. GET страницы логина — начальные cookie + requesttoken.
|
||||||
|
val loginHtml = client.newCall(
|
||||||
|
Request.Builder().url("$base/login").header("User-Agent", USER_AGENT).get().build(),
|
||||||
|
).execute().use { it.body?.string().orEmpty() }
|
||||||
|
val token = requestTokenRegex.find(loginHtml)?.groupValues?.getOrNull(1).orEmpty()
|
||||||
|
|
||||||
|
// 2. POST формы логина app-паролем — сервер ставит session-cookie.
|
||||||
|
val form = FormBody.Builder()
|
||||||
|
.add("user", username)
|
||||||
|
.add("password", appPassword)
|
||||||
|
.apply { if (token.isNotBlank()) add("requesttoken", token) }
|
||||||
|
.add("remember_login", "1")
|
||||||
|
.add("timezone", "UTC")
|
||||||
|
.add("timezone_offset", "0")
|
||||||
|
.build()
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("$base/login")
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.apply { if (token.isNotBlank()) header("requesttoken", token) }
|
||||||
|
.header("OCS-APIRequest", "true")
|
||||||
|
.post(form)
|
||||||
|
.build(),
|
||||||
|
).execute().use { /* cookie осели в jar */ }
|
||||||
|
|
||||||
|
return jar.cookiesForHost(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val USER_AGENT = "Mozilla/5.0 (Android) F7cloud-Mobile"
|
||||||
|
|
||||||
|
private class MemoryCookieJar : CookieJar {
|
||||||
|
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
|
||||||
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||||
|
val list = store.getOrPut(url.host) { mutableListOf() }
|
||||||
|
cookies.forEach { c ->
|
||||||
|
list.removeAll { it.name == c.name }
|
||||||
|
list += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||||
|
store[url.host].orEmpty().filter { it.matches(url) }
|
||||||
|
fun cookiesForHost(host: String): List<Cookie> = store[host].orEmpty().toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ dependencies {
|
|||||||
implementation composeBom
|
implementation composeBom
|
||||||
implementation libs.compose.ui
|
implementation libs.compose.ui
|
||||||
implementation libs.compose.material3
|
implementation libs.compose.material3
|
||||||
|
implementation libs.compose.material.icons.extended
|
||||||
implementation libs.compose.foundation
|
implementation libs.compose.foundation
|
||||||
implementation libs.lifecycle.viewmodel.compose
|
implementation libs.lifecycle.viewmodel.compose
|
||||||
implementation libs.activity.compose
|
implementation libs.activity.compose
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ data class CalendarLocationSuggestion(
|
|||||||
val address: String,
|
val address: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CalendarTalkRoom(
|
||||||
|
val token: String,
|
||||||
|
val displayName: String,
|
||||||
|
val callUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarShareeSuggestion(
|
||||||
|
val displayName: String,
|
||||||
|
/** DAV-принципал: principal:principals/users/<id> или groups/<id>. */
|
||||||
|
val principal: String,
|
||||||
|
val isGroup: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
data class CalendarUserSettings(
|
data class CalendarUserSettings(
|
||||||
val timezone: String = "automatic",
|
val timezone: String = "automatic",
|
||||||
val showWeekends: Boolean = true,
|
val showWeekends: Boolean = true,
|
||||||
@@ -106,6 +119,105 @@ class CalendarApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Email текущего пользователя (для ORGANIZER в iTIP free-busy). */
|
||||||
|
fun getUserEmail(session: AuthSession): String {
|
||||||
|
val client = authedClient(session)
|
||||||
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
|
||||||
|
val request = Request.Builder().url(url).get().applyOcsJson().build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val json = parseJsonObject(response.body?.string().orEmpty(), "user info")
|
||||||
|
json.optJSONObject("ocs")?.optJSONObject("data")?.optString("email").orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск пользователей/групп для шаринга календаря (OCS sharees). */
|
||||||
|
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> {
|
||||||
|
if (query.trim().length < 2) return emptyList()
|
||||||
|
val client = authedClient(session)
|
||||||
|
val q = java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())
|
||||||
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v1.php/apps/files_sharing/api/v1/sharees" +
|
||||||
|
"?format=json&itemType=calendar&perPage=10&search=$q"
|
||||||
|
val request = Request.Builder().url(url).get().applyOcsJson().build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val json = parseJsonObject(response.body?.string().orEmpty(), "sharees")
|
||||||
|
val data = json.optJSONObject("ocs")?.optJSONObject("data") ?: return emptyList()
|
||||||
|
val out = mutableListOf<CalendarShareeSuggestion>()
|
||||||
|
fun collect(arrName: String, isGroup: Boolean) {
|
||||||
|
val exactWrap = data.optJSONObject("exact")?.optJSONArray(arrName) ?: JSONArray()
|
||||||
|
val plain = data.optJSONArray(arrName) ?: JSONArray()
|
||||||
|
for (arr in listOf(exactWrap, plain)) {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val item = arr.optJSONObject(i) ?: continue
|
||||||
|
val id = item.optJSONObject("value")?.optString("shareWith").orEmpty()
|
||||||
|
if (id.isBlank()) continue
|
||||||
|
val kind = if (isGroup) "groups" else "users"
|
||||||
|
out += CalendarShareeSuggestion(
|
||||||
|
displayName = item.optString("label").ifBlank { id },
|
||||||
|
principal = "principal:principals/$kind/$id",
|
||||||
|
isGroup = isGroup,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect("users", isGroup = false)
|
||||||
|
collect("groups", isGroup = true)
|
||||||
|
out.distinctBy { it.principal }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Существующие беседы Talk (для выбора комнаты в событии). */
|
||||||
|
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> {
|
||||||
|
val client = authedClient(session)
|
||||||
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
|
||||||
|
val request = Request.Builder().url(url).get().applyOcsJson().build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val json = parseJsonObject(response.body?.string().orEmpty(), "list talk rooms")
|
||||||
|
if (!isOcsSuccess(json.ocsMeta())) return emptyList()
|
||||||
|
val data = json.optJSONObject("ocs")?.optJSONArray("data") ?: JSONArray()
|
||||||
|
val out = mutableListOf<CalendarTalkRoom>()
|
||||||
|
for (i in 0 until data.length()) {
|
||||||
|
val room = data.optJSONObject(i) ?: continue
|
||||||
|
val token = room.optString("token")
|
||||||
|
if (token.isBlank()) continue
|
||||||
|
val name = room.optString("displayName").ifBlank { room.optString("name") }
|
||||||
|
out += CalendarTalkRoom(
|
||||||
|
token = token,
|
||||||
|
displayName = name.ifBlank { "Беседа" },
|
||||||
|
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создать беседу заданного типа (2 — групповая/публичная, 3 — публичная по ссылке). */
|
||||||
|
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom {
|
||||||
|
val client = authedClient(session)
|
||||||
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
|
||||||
|
val payload = JSONObject()
|
||||||
|
.put("roomType", roomType)
|
||||||
|
.put("roomName", roomName.trim().ifBlank { "Встреча" })
|
||||||
|
.put("readOnly", 0)
|
||||||
|
.put("listable", 0)
|
||||||
|
.toString()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||||
|
.applyOcsJson()
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
|
||||||
|
if (!isOcsSuccess(json.ocsMeta())) error("Не удалось создать беседу Talk")
|
||||||
|
val token = json.optJSONObject("ocs")?.optJSONObject("data")?.optString("token").orEmpty()
|
||||||
|
if (token.isBlank()) error("Не удалось создать беседу Talk")
|
||||||
|
return CalendarTalkRoom(
|
||||||
|
token = token,
|
||||||
|
displayName = roomName.trim().ifBlank { "Встреча" },
|
||||||
|
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createTalkRoom(session: AuthSession, roomName: String): String {
|
fun createTalkRoom(session: AuthSession, roomName: String): String {
|
||||||
val client = authedClient(session)
|
val client = authedClient(session)
|
||||||
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
|
||||||
|
|||||||
@@ -23,26 +23,26 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.DatePicker
|
|
||||||
import androidx.compose.material3.DatePickerDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -77,7 +77,6 @@ import java.util.Locale
|
|||||||
private val weekDayLabels = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
|
private val weekDayLabels = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
|
||||||
private val dayTitleFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))
|
private val dayTitleFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))
|
||||||
private val listDayFormatter = DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.forLanguageTag("ru"))
|
private val listDayFormatter = DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.forLanguageTag("ru"))
|
||||||
private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)
|
|
||||||
private val dayHeaderFormatter = DateTimeFormatter.ofPattern("EE dd.M.yyyy", Locale.forLanguageTag("ru"))
|
private val dayHeaderFormatter = DateTimeFormatter.ofPattern("EE dd.M.yyyy", Locale.forLanguageTag("ru"))
|
||||||
|
|
||||||
private fun viewModeIconUrl(base: String, mode: CalendarViewMode): String = when (mode) {
|
private fun viewModeIconUrl(base: String, mode: CalendarViewMode): String = when (mode) {
|
||||||
@@ -865,6 +864,7 @@ fun CalendarNavigationSidebar(
|
|||||||
onOpenTasks: () -> Unit,
|
onOpenTasks: () -> Unit,
|
||||||
onCreateBook: () -> Unit,
|
onCreateBook: () -> Unit,
|
||||||
onSyncNow: () -> Unit,
|
onSyncNow: () -> Unit,
|
||||||
|
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val base = session.serverUrl.trimEnd('/')
|
val base = session.serverUrl.trimEnd('/')
|
||||||
val (myCalendars, deckCalendars) = remember(state.calendars) {
|
val (myCalendars, deckCalendars) = remember(state.calendars) {
|
||||||
@@ -916,7 +916,7 @@ fun CalendarNavigationSidebar(
|
|||||||
onAdd = onCreateBook,
|
onAdd = onCreateBook,
|
||||||
)
|
)
|
||||||
myCalendars.forEach { cal ->
|
myCalendars.forEach { cal ->
|
||||||
CalendarSidebarCalendarRow(cal, onToggleCalendar)
|
CalendarSidebarCalendarRow(cal, onToggleCalendar, onEditCalendar)
|
||||||
}
|
}
|
||||||
if (deckCalendars.isNotEmpty()) {
|
if (deckCalendars.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -1006,6 +1006,7 @@ private fun CalendarSidebarCaption(
|
|||||||
private fun CalendarSidebarCalendarRow(
|
private fun CalendarSidebarCalendarRow(
|
||||||
cal: CalendarBookItem,
|
cal: CalendarBookItem,
|
||||||
onToggleCalendar: (String) -> Unit,
|
onToggleCalendar: (String) -> Unit,
|
||||||
|
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val accent = calendarAccentColor(cal)
|
val accent = calendarAccentColor(cal)
|
||||||
Row(
|
Row(
|
||||||
@@ -1029,7 +1030,14 @@ private fun CalendarSidebarCalendarRow(
|
|||||||
color = F7Colors.TextPrimary,
|
color = F7Colors.TextPrimary,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
// Подписки редактировать нельзя (PROPPATCH/шаринг не применимы)
|
||||||
|
if (onEditCalendar != null && !cal.isSubscription) {
|
||||||
|
IconButton(onClick = { onEditCalendar(cal) }, modifier = Modifier.size(28.dp)) {
|
||||||
|
Text("✎", color = F7Colors.TextSecondary, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,23 +1225,26 @@ fun CalendarDatePickerDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onConfirm: (LocalDate) -> Unit,
|
onConfirm: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val state = rememberDatePickerState(
|
// Нативный DatePickerDialog вместо material3.DatePicker: material3-версия (BOM 2025.02)
|
||||||
initialSelectedDateMillis = selectedDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
// падала при открытии, а платформенный диалог стабилен и сам локализуется под системную
|
||||||
)
|
// локаль (русские месяцы). Показываем один раз, пока композабл присутствует в дереве.
|
||||||
DatePickerDialog(
|
val context = LocalContext.current
|
||||||
onDismissRequest = onDismiss,
|
val currentOnConfirm by rememberUpdatedState(onConfirm)
|
||||||
confirmButton = {
|
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||||
TextButton(onClick = {
|
DisposableEffect(Unit) {
|
||||||
val millis = state.selectedDateMillis ?: return@TextButton
|
val dialog = android.app.DatePickerDialog(
|
||||||
val day = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
|
context,
|
||||||
onConfirm(day)
|
{ _, year, month, dayOfMonth ->
|
||||||
}) { Text("OK") }
|
currentOnConfirm(LocalDate.of(year, month + 1, dayOfMonth))
|
||||||
},
|
},
|
||||||
dismissButton = {
|
selectedDay.year,
|
||||||
TextButton(onClick = onDismiss) { Text("Отмена") }
|
selectedDay.monthValue - 1,
|
||||||
},
|
selectedDay.dayOfMonth,
|
||||||
) {
|
)
|
||||||
DatePicker(state = state)
|
// onDismiss закрывает и по «Отмена», и после выбора (диалог сам себя закрывает).
|
||||||
|
dialog.setOnDismissListener { currentOnDismiss() }
|
||||||
|
dialog.show()
|
||||||
|
onDispose { dialog.setOnDismissListener(null); dialog.dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ data class CalendarTaskItem(
|
|||||||
data class CalendarEventDraft(
|
data class CalendarEventDraft(
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val date: java.time.LocalDate = java.time.LocalDate.now(),
|
val date: java.time.LocalDate = java.time.LocalDate.now(),
|
||||||
|
/** Дата окончания; null = совпадает с датой начала (однодневное событие). */
|
||||||
|
val endDate: java.time.LocalDate? = null,
|
||||||
val startTime: String = "10:00",
|
val startTime: String = "10:00",
|
||||||
val endTime: String = "11:00",
|
val endTime: String = "11:00",
|
||||||
val allDay: Boolean = false,
|
val allDay: Boolean = false,
|
||||||
@@ -82,9 +84,17 @@ data class CalendarEventDraft(
|
|||||||
val classification: EventClassification = EventClassification.PUBLIC,
|
val classification: EventClassification = EventClassification.PUBLIC,
|
||||||
val attendees: List<CalendarAttendeeItem> = emptyList(),
|
val attendees: List<CalendarAttendeeItem> = emptyList(),
|
||||||
val attendeeQuery: String = "",
|
val attendeeQuery: String = "",
|
||||||
val reminderMinutes: Int = 15,
|
/** Напоминания в минутах до начала (0 = во время начала); поддерживается несколько. */
|
||||||
|
val reminders: List<Int> = listOf(15),
|
||||||
val addTalkRoom: Boolean = false,
|
val addTalkRoom: Boolean = false,
|
||||||
val talkRoomUrl: String = "",
|
val talkRoomUrl: String = "",
|
||||||
|
val attachments: List<CalendarAttachmentItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CalendarAttachmentItem(
|
||||||
|
val fileName: String,
|
||||||
|
val url: String,
|
||||||
|
val mimeType: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CalendarCreateBookDraft(
|
data class CalendarCreateBookDraft(
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
package ru.forbion.f7cloud.feature.calendar
|
package ru.forbion.f7cloud.feature.calendar
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
import ru.forbion.f7cloud.core.auth.OcsUserResolver
|
import ru.forbion.f7cloud.core.auth.OcsUserResolver
|
||||||
import ru.forbion.f7cloud.core.network.CalendarAlarmData
|
import ru.forbion.f7cloud.core.network.CalendarAlarmData
|
||||||
|
import ru.forbion.f7cloud.core.network.CalendarAttachmentData
|
||||||
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
|
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
|
||||||
import ru.forbion.f7cloud.core.network.CalendarEventData
|
import ru.forbion.f7cloud.core.network.CalendarEventData
|
||||||
import ru.forbion.f7cloud.core.network.CalendarIcs
|
import ru.forbion.f7cloud.core.network.CalendarIcs
|
||||||
import ru.forbion.f7cloud.core.network.CalDavClient
|
import ru.forbion.f7cloud.core.network.CalDavClient
|
||||||
import ru.forbion.f7cloud.core.network.DavCalendar
|
import ru.forbion.f7cloud.core.network.DavCalendar
|
||||||
|
import ru.forbion.f7cloud.core.network.DavClient
|
||||||
import ru.forbion.f7cloud.core.network.DavEvent
|
import ru.forbion.f7cloud.core.network.DavEvent
|
||||||
import ru.forbion.f7cloud.core.network.DavTask
|
import ru.forbion.f7cloud.core.network.DavTask
|
||||||
import ru.forbion.f7cloud.core.network.DavTrashEvent
|
import ru.forbion.f7cloud.core.network.DavTrashEvent
|
||||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.davFileUrl
|
||||||
|
import ru.forbion.f7cloud.core.network.davFolderUrl
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -104,13 +110,111 @@ class CalendarRepository(
|
|||||||
|
|
||||||
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
|
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
|
||||||
|
|
||||||
|
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> = apiClient.listTalkRooms(session)
|
||||||
|
|
||||||
|
private fun davUserId(session: AuthSession): String =
|
||||||
|
session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||||
|
|
||||||
|
// --- Управление календарём ---
|
||||||
|
fun updateCalendarProps(session: AuthSession, calendarHref: String, displayName: String?, color: String?) {
|
||||||
|
CalDavClient.updateCalendarProps(davClient(session), calendarHref, displayName, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCalendar(session: AuthSession, calendarHref: String) {
|
||||||
|
CalDavClient.deleteCalendar(davClient(session), calendarHref)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listCalendarShares(session: AuthSession, calendarHref: String): List<CalDavClient.DavSharee> =
|
||||||
|
CalDavClient.listCalendarShares(davClient(session), calendarHref)
|
||||||
|
|
||||||
|
fun shareCalendar(session: AuthSession, calendarHref: String, principal: String, writable: Boolean) {
|
||||||
|
CalDavClient.shareCalendar(davClient(session), calendarHref, principal, writable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unshareCalendar(session: AuthSession, calendarHref: String, principal: String) {
|
||||||
|
CalDavClient.unshareCalendar(davClient(session), calendarHref, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> =
|
||||||
|
apiClient.searchSharees(session, query)
|
||||||
|
|
||||||
|
/** Занятость участников (email → интервалы) за диапазон. */
|
||||||
|
fun freeBusy(
|
||||||
|
session: AuthSession,
|
||||||
|
attendeeEmails: List<String>,
|
||||||
|
rangeStart: Instant,
|
||||||
|
rangeEnd: Instant,
|
||||||
|
): Map<String, List<CalDavClient.BusyPeriod>> {
|
||||||
|
val organizer = apiClient.getUserEmail(session)
|
||||||
|
if (organizer.isBlank()) error("У пользователя не задан email — занятость недоступна")
|
||||||
|
return CalDavClient.freeBusy(
|
||||||
|
davClient(session),
|
||||||
|
session.serverUrl,
|
||||||
|
davUserId(session),
|
||||||
|
organizer,
|
||||||
|
attendeeEmails,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun davClient(session: AuthSession): OkHttpClient =
|
||||||
|
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||||
|
|
||||||
|
/** Загрузка файла с устройства в папку /Calendar и возврат ATTACH-элемента (WebDAV-URL). */
|
||||||
|
fun uploadAttachment(
|
||||||
|
session: AuthSession,
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
): CalendarAttachmentItem {
|
||||||
|
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||||
|
val userId = davUserId(session)
|
||||||
|
// Папка для вложений календаря; создаём best-effort (уже существует → игнорируем).
|
||||||
|
runCatching { DavClient.mkcol(client, davFolderUrl(session.serverUrl, userId, "Calendar")) }
|
||||||
|
val safeName = sanitizeFileName(fileName)
|
||||||
|
val fileUrl = davFileUrl(session.serverUrl, userId, "Calendar/$safeName")
|
||||||
|
val media = mimeType.ifBlank { "application/octet-stream" }.toMediaType()
|
||||||
|
DavClient.put(client, fileUrl, bytes.toRequestBody(media))
|
||||||
|
return CalendarAttachmentItem(fileName = safeName, url = fileUrl, mimeType = mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список файлов/папок в директории (для пикера «Добавить из файлов»). relativePath — от корня. */
|
||||||
|
fun listUserFiles(session: AuthSession, relativePath: String): List<DavClient.DavEntry> {
|
||||||
|
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||||
|
val userId = davUserId(session)
|
||||||
|
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
|
||||||
|
val entries = DavClient.propfind(client, folderUrl, depth = 1)
|
||||||
|
val self = folderUrl.trimEnd('/')
|
||||||
|
// Убираем саму папку (первый ответ PROPFIND) и скрытые файлы.
|
||||||
|
return entries
|
||||||
|
.filter { it.href.trimEnd('/') != self && !it.name.startsWith('.') }
|
||||||
|
.sortedWith(compareByDescending<DavClient.DavEntry> { it.isDirectory }.thenBy { it.name.lowercase() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полный WebDAV-URL файла (для ATTACH при выборе из Файлов). */
|
||||||
|
fun fileAttachment(session: AuthSession, relativePath: String, mimeType: String): CalendarAttachmentItem {
|
||||||
|
val userId = davUserId(session)
|
||||||
|
val url = davFileUrl(session.serverUrl, userId, relativePath)
|
||||||
|
return CalendarAttachmentItem(fileName = relativePath.substringAfterLast('/'), url = url, mimeType = mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeFileName(name: String): String =
|
||||||
|
name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
|
||||||
|
.replace(Regex("[\\r\\n\"]"), "_")
|
||||||
|
|
||||||
|
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom =
|
||||||
|
apiClient.createTalkRoomTyped(session, roomName, roomType)
|
||||||
|
|
||||||
fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
|
fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
|
||||||
apiClient.addTalkParticipants(session, callUrl, emails)
|
apiClient.addTalkParticipants(session, callUrl, emails)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
|
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
|
||||||
val ctx = openDavContext(session)
|
val ctx = openDavContext(session)
|
||||||
val calendar = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
|
val picked = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
|
||||||
|
// Если выбранная коллекция не принимает VEVENT (список задач) — уводим в событийный дефолт.
|
||||||
|
val calendar = if (picked.supportsEvents) picked else ctx.defaultCalendar
|
||||||
var talkUrl = draft.talkRoomUrl
|
var talkUrl = draft.talkRoomUrl
|
||||||
if (draft.addTalkRoom && talkUrl.isBlank()) {
|
if (draft.addTalkRoom && talkUrl.isBlank()) {
|
||||||
talkUrl = apiClient.createTalkRoom(session, draft.title)
|
talkUrl = apiClient.createTalkRoom(session, draft.title)
|
||||||
@@ -197,10 +301,12 @@ class CalendarRepository(
|
|||||||
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
|
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
|
||||||
val calendars = CalDavClient.listCalendars(client, base)
|
val calendars = CalDavClient.listCalendars(client, base)
|
||||||
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
|
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
|
||||||
val default = calendars.firstOrNull { cal ->
|
// Дефолт под события — только событийная коллекция (не VTODO-список задач), иначе PUT 403.
|
||||||
|
val eventCals = calendars.filter { it.supportsEvents }.ifEmpty { calendars }
|
||||||
|
val default = eventCals.firstOrNull { cal ->
|
||||||
val name = cal.displayName.lowercase()
|
val name = cal.displayName.lowercase()
|
||||||
name.contains("личн") || name.contains("personal") || name == "calendar"
|
name.contains("личн") || name.contains("personal") || name == "calendar"
|
||||||
} ?: calendars.first()
|
} ?: eventCals.first()
|
||||||
return CalDavContext(client, calendars, default)
|
return CalDavContext(client, calendars, default)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +370,7 @@ class CalendarRepository(
|
|||||||
organizerEmail = organizerEmail,
|
organizerEmail = organizerEmail,
|
||||||
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
|
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
|
||||||
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
|
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
|
||||||
|
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
|
||||||
source = CalendarEventSource.F7CLOUD,
|
source = CalendarEventSource.F7CLOUD,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -361,23 +468,27 @@ data class CalendarEventItem(
|
|||||||
val organizerEmail: String = "",
|
val organizerEmail: String = "",
|
||||||
val talkRoomUrl: String = "",
|
val talkRoomUrl: String = "",
|
||||||
val alarms: List<CalendarReminderItem> = emptyList(),
|
val alarms: List<CalendarReminderItem> = emptyList(),
|
||||||
|
val attachments: List<CalendarAttachmentItem> = emptyList(),
|
||||||
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
|
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
|
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
|
// Многодневные события: дата окончания отдельная (не раньше даты начала).
|
||||||
|
val endDay = (endDate ?: date).coerceAtLeast(date)
|
||||||
val start = if (allDay) {
|
val start = if (allDay) {
|
||||||
date.atStartOfDay(zone).toInstant().toEpochMilli()
|
date.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||||
} else {
|
} else {
|
||||||
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||||
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
|
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
|
||||||
}
|
}
|
||||||
val end = if (allDay) {
|
val endRaw = if (allDay) {
|
||||||
date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||||
} else {
|
} else {
|
||||||
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
parseDateTime(endDay, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||||
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
|
?: endDay.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
|
||||||
}
|
}
|
||||||
|
val end = endRaw.coerceAtLeast(start)
|
||||||
val rrule = when {
|
val rrule = when {
|
||||||
recurrence != RecurrencePreset.NONE -> recurrence.rrule
|
recurrence != RecurrencePreset.NONE -> recurrence.rrule
|
||||||
customRrule.isNotBlank() -> customRrule
|
customRrule.isNotBlank() -> customRrule
|
||||||
@@ -400,8 +511,9 @@ private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String
|
|||||||
attendees = attendees.map {
|
attendees = attendees.map {
|
||||||
CalendarAttendeeData(it.email, it.displayName, it.partStat)
|
CalendarAttendeeData(it.email, it.displayName, it.partStat)
|
||||||
},
|
},
|
||||||
alarms = if (reminderMinutes > 0) listOf(CalendarAlarmData(reminderMinutes)) else emptyList(),
|
alarms = reminders.filter { it >= 0 }.distinct().map { CalendarAlarmData(it) },
|
||||||
conferenceUri = conf,
|
conferenceUri = conf,
|
||||||
|
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +533,7 @@ private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventDa
|
|||||||
organizerEmail = organizerEmail,
|
organizerEmail = organizerEmail,
|
||||||
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
|
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
|
||||||
conferenceUri = talkRoomUrl,
|
conferenceUri = talkRoomUrl,
|
||||||
|
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CalendarEventData.toItem(
|
private fun CalendarEventData.toItem(
|
||||||
@@ -448,6 +561,7 @@ private fun CalendarEventData.toItem(
|
|||||||
attendees = attendees.map { it.toItem() },
|
attendees = attendees.map { it.toItem() },
|
||||||
talkRoomUrl = conferenceUri,
|
talkRoomUrl = conferenceUri,
|
||||||
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
|
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
|
||||||
|
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
|
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ fun CalendarScreen(
|
|||||||
vm.updateEventDraft { it } // no-op; snack via error path if needed
|
vm.updateEventDraft { it } // no-op; snack via error path if needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val attachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||||
|
if (uri != null) vm.uploadDeviceAttachment(uri)
|
||||||
|
}
|
||||||
|
|
||||||
fun hasCalendarPermission(): Boolean {
|
fun hasCalendarPermission(): Boolean {
|
||||||
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||||
@@ -220,6 +223,48 @@ fun CalendarScreen(
|
|||||||
onRemoveAttendee = vm::removeAttendee,
|
onRemoveAttendee = vm::removeAttendee,
|
||||||
onSearchLocations = vm::searchLocations,
|
onSearchLocations = vm::searchLocations,
|
||||||
onApplyLocation = vm::applyLocationSuggestion,
|
onApplyLocation = vm::applyLocationSuggestion,
|
||||||
|
onOpenTalkPicker = vm::openTalkPicker,
|
||||||
|
onPickDeviceAttachment = { attachmentLauncher.launch("*/*") },
|
||||||
|
onPickFilesAttachment = vm::openFilesPicker,
|
||||||
|
onRemoveAttachment = vm::removeAttachment,
|
||||||
|
onOpenFreeBusy = vm::openFreeBusy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.freeBusyOpen) {
|
||||||
|
CalendarFreeBusyDialog(
|
||||||
|
state = state,
|
||||||
|
onDismiss = vm::closeFreeBusy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.talkPickerOpen) {
|
||||||
|
CalendarTalkPickerDialog(
|
||||||
|
state = state,
|
||||||
|
onDismiss = vm::closeTalkPicker,
|
||||||
|
onSelect = vm::selectTalkRoom,
|
||||||
|
onCreate = vm::createTalkConversation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.filesPickerOpen) {
|
||||||
|
CalendarFilesPickerDialog(
|
||||||
|
state = state,
|
||||||
|
onDismiss = vm::closeFilesPicker,
|
||||||
|
onNavigate = vm::navigateFilesPicker,
|
||||||
|
onPick = vm::pickFileAttachment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.editCalendarOpen) {
|
||||||
|
CalendarEditBookDialog(
|
||||||
|
state = state,
|
||||||
|
onDismiss = vm::closeEditCalendar,
|
||||||
|
onSave = vm::saveCalendarProps,
|
||||||
|
onDelete = vm::deleteCalendarConfirmed,
|
||||||
|
onSearchSharees = vm::searchSharees,
|
||||||
|
onAddSharee = vm::addSharee,
|
||||||
|
onRemoveSharee = vm::removeSharee,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +332,11 @@ fun CalendarScreen(
|
|||||||
onSyncNow = {
|
onSyncNow = {
|
||||||
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
|
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
|
||||||
},
|
},
|
||||||
|
onEditCalendar = { cal ->
|
||||||
|
onSidebarOpenChange(false)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
vm.openEditCalendar(cal)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ data class CalendarUiState(
|
|||||||
val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
|
val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
|
||||||
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
|
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
|
||||||
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
|
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
|
||||||
|
val talkPickerOpen: Boolean = false,
|
||||||
|
val talkPickerLoading: Boolean = false,
|
||||||
|
val talkRooms: List<CalendarTalkRoom> = emptyList(),
|
||||||
|
val attachmentUploading: Boolean = false,
|
||||||
|
val editCalendarOpen: Boolean = false,
|
||||||
|
val editCalendarTarget: CalendarBookItem? = null,
|
||||||
|
val editCalendarShares: List<ru.forbion.f7cloud.core.network.CalDavClient.DavSharee> = emptyList(),
|
||||||
|
val shareeSuggestions: List<CalendarShareeSuggestion> = emptyList(),
|
||||||
|
val editCalendarBusy: Boolean = false,
|
||||||
|
val freeBusyOpen: Boolean = false,
|
||||||
|
val freeBusyLoading: Boolean = false,
|
||||||
|
val freeBusyError: String? = null,
|
||||||
|
val freeBusyResult: Map<String, List<ru.forbion.f7cloud.core.network.CalDavClient.BusyPeriod>> = emptyMap(),
|
||||||
|
val filesPickerOpen: Boolean = false,
|
||||||
|
val filesPickerLoading: Boolean = false,
|
||||||
|
val filesPickerPath: String = "",
|
||||||
|
val filesPickerEntries: List<ru.forbion.f7cloud.core.network.DavClient.DavEntry> = emptyList(),
|
||||||
val userSettings: CalendarUserSettings = CalendarUserSettings(),
|
val userSettings: CalendarUserSettings = CalendarUserSettings(),
|
||||||
val saving: Boolean = false,
|
val saving: Boolean = false,
|
||||||
val deleting: Boolean = false,
|
val deleting: Boolean = false,
|
||||||
@@ -362,7 +379,7 @@ class CalendarViewModel(
|
|||||||
eventDraft = CalendarEventDraft(
|
eventDraft = CalendarEventDraft(
|
||||||
date = targetDay,
|
date = targetDay,
|
||||||
calendarHref = createHref,
|
calendarHref = createHref,
|
||||||
reminderMinutes = _state.value.userSettings.defaultReminderMinutes,
|
reminders = listOf(_state.value.userSettings.defaultReminderMinutes),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -410,6 +427,212 @@ class CalendarViewModel(
|
|||||||
_state.value = _state.value.copy(locationSuggestions = emptyList())
|
_state.value = _state.value.copy(locationSuggestions = emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openTalkPicker() {
|
||||||
|
val s = session ?: return
|
||||||
|
_state.value = _state.value.copy(talkPickerOpen = true, talkPickerLoading = true, talkRooms = emptyList())
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val rooms = runCatching { repository.listTalkRooms(s) }.getOrDefault(emptyList())
|
||||||
|
_state.value = _state.value.copy(talkRooms = rooms, talkPickerLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeTalkPicker() { _state.value = _state.value.copy(talkPickerOpen = false) }
|
||||||
|
|
||||||
|
fun selectTalkRoom(room: CalendarTalkRoom) {
|
||||||
|
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
|
||||||
|
_state.value = _state.value.copy(talkPickerOpen = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** roomType: 2 — публичная беседа, 3 — приватная (в терминах spreed). */
|
||||||
|
fun createTalkConversation(roomType: Int) {
|
||||||
|
val s = session ?: return
|
||||||
|
val name = _state.value.eventDraft.title.ifBlank { "Встреча" }
|
||||||
|
_state.value = _state.value.copy(talkPickerLoading = true)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { repository.createTalkRoomTyped(s, name, roomType) }
|
||||||
|
.onSuccess { room ->
|
||||||
|
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
|
||||||
|
_state.value = _state.value.copy(talkPickerOpen = false, talkPickerLoading = false)
|
||||||
|
}
|
||||||
|
.onFailure { _state.value = _state.value.copy(talkPickerLoading = false, snackMessage = "Не удалось создать беседу") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Управление календарём ---
|
||||||
|
fun openEditCalendar(cal: CalendarBookItem) {
|
||||||
|
val s = session ?: return
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
editCalendarOpen = true,
|
||||||
|
editCalendarTarget = cal,
|
||||||
|
editCalendarShares = emptyList(),
|
||||||
|
shareeSuggestions = emptyList(),
|
||||||
|
)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val shares = runCatching { repository.listCalendarShares(s, cal.href) }.getOrDefault(emptyList())
|
||||||
|
_state.value = _state.value.copy(editCalendarShares = shares)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeEditCalendar() {
|
||||||
|
_state.value = _state.value.copy(editCalendarOpen = false, editCalendarTarget = null, shareeSuggestions = emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCalendarProps(displayName: String, color: String?) {
|
||||||
|
val s = session ?: return
|
||||||
|
val target = _state.value.editCalendarTarget ?: return
|
||||||
|
_state.value = _state.value.copy(editCalendarBusy = true)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
repository.updateCalendarProps(
|
||||||
|
s,
|
||||||
|
target.href,
|
||||||
|
displayName.trim().takeIf { it.isNotBlank() && it != target.displayName },
|
||||||
|
color?.takeIf { it != target.color },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
loadCalendars(s)
|
||||||
|
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь обновлён")
|
||||||
|
}
|
||||||
|
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось обновить: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCalendarConfirmed() {
|
||||||
|
val s = session ?: return
|
||||||
|
val target = _state.value.editCalendarTarget ?: return
|
||||||
|
_state.value = _state.value.copy(editCalendarBusy = true)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { repository.deleteCalendar(s, target.href) }
|
||||||
|
.onSuccess {
|
||||||
|
loadCalendars(s)
|
||||||
|
reloadEvents(s)
|
||||||
|
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь удалён")
|
||||||
|
}
|
||||||
|
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось удалить: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchSharees(query: String) {
|
||||||
|
val s = session ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val found = runCatching { repository.searchSharees(s, query) }.getOrDefault(emptyList())
|
||||||
|
_state.value = _state.value.copy(shareeSuggestions = found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSharee(sharee: CalendarShareeSuggestion, writable: Boolean) {
|
||||||
|
val s = session ?: return
|
||||||
|
val target = _state.value.editCalendarTarget ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { repository.shareCalendar(s, target.href, sharee.principal, writable) }
|
||||||
|
.onSuccess {
|
||||||
|
val shares = runCatching { repository.listCalendarShares(s, target.href) }.getOrDefault(emptyList())
|
||||||
|
_state.value = _state.value.copy(editCalendarShares = shares, shareeSuggestions = emptyList())
|
||||||
|
}
|
||||||
|
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось пошарить: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeSharee(principal: String) {
|
||||||
|
val s = session ?: return
|
||||||
|
val target = _state.value.editCalendarTarget ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { repository.unshareCalendar(s, target.href, principal) }
|
||||||
|
.onSuccess {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
editCalendarShares = _state.value.editCalendarShares.filterNot { it.principal == principal },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось убрать доступ: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Занятость участников (free/busy) ---
|
||||||
|
fun openFreeBusy() {
|
||||||
|
val s = session ?: return
|
||||||
|
val draft = _state.value.eventDraft
|
||||||
|
val emails = draft.attendees.map { it.email }.filter { it.isNotBlank() }
|
||||||
|
if (emails.isEmpty()) return
|
||||||
|
_state.value = _state.value.copy(freeBusyOpen = true, freeBusyLoading = true, freeBusyError = null, freeBusyResult = emptyMap())
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
// Диапазон — весь день события (локальные сутки).
|
||||||
|
val start = draft.date.atStartOfDay(zone).toInstant()
|
||||||
|
val end = draft.date.plusDays(1).atStartOfDay(zone).toInstant()
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { repository.freeBusy(s, emails, start, end) }
|
||||||
|
.onSuccess { _state.value = _state.value.copy(freeBusyResult = it, freeBusyLoading = false) }
|
||||||
|
.onFailure { _state.value = _state.value.copy(freeBusyLoading = false, freeBusyError = it.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeFreeBusy() { _state.value = _state.value.copy(freeBusyOpen = false) }
|
||||||
|
|
||||||
|
// --- Вложения ---
|
||||||
|
fun uploadDeviceAttachment(uri: android.net.Uri) {
|
||||||
|
val s = session ?: return
|
||||||
|
_state.value = _state.value.copy(attachmentUploading = true)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val resolver = appContext.contentResolver
|
||||||
|
val name = queryDisplayName(uri) ?: "attachment"
|
||||||
|
val mime = resolver.getType(uri).orEmpty()
|
||||||
|
val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
|
||||||
|
if (bytes == null || bytes.isEmpty()) {
|
||||||
|
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Не удалось прочитать файл")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runCatching { repository.uploadAttachment(s, name, mime, bytes) }
|
||||||
|
.onSuccess { item ->
|
||||||
|
updateEventDraft { it.copy(attachments = it.attachments + item) }
|
||||||
|
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Файл загружен")
|
||||||
|
}
|
||||||
|
.onFailure { _state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Ошибка загрузки: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFilesPicker() {
|
||||||
|
_state.value = _state.value.copy(filesPickerOpen = true)
|
||||||
|
loadFilesPicker("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeFilesPicker() { _state.value = _state.value.copy(filesPickerOpen = false) }
|
||||||
|
|
||||||
|
fun navigateFilesPicker(path: String) = loadFilesPicker(path)
|
||||||
|
|
||||||
|
private fun loadFilesPicker(path: String) {
|
||||||
|
val s = session ?: return
|
||||||
|
_state.value = _state.value.copy(filesPickerLoading = true, filesPickerPath = path)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val entries = runCatching { repository.listUserFiles(s, path) }.getOrDefault(emptyList())
|
||||||
|
_state.value = _state.value.copy(filesPickerEntries = entries, filesPickerLoading = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pickFileAttachment(entry: ru.forbion.f7cloud.core.network.DavClient.DavEntry) {
|
||||||
|
val s = session ?: return
|
||||||
|
val path = _state.value.filesPickerPath
|
||||||
|
val rel = if (path.isBlank()) entry.name else "$path/${entry.name}"
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
runCatching { repository.fileAttachment(s, rel, entry.mimeType.orEmpty()) }
|
||||||
|
.onSuccess { item ->
|
||||||
|
updateEventDraft { it.copy(attachments = it.attachments + item) }
|
||||||
|
_state.value = _state.value.copy(filesPickerOpen = false, snackMessage = "Файл прикреплён")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAttachment(url: String) {
|
||||||
|
updateEventDraft { d -> d.copy(attachments = d.attachments.filterNot { it.url == url }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryDisplayName(uri: android.net.Uri): String? =
|
||||||
|
runCatching {
|
||||||
|
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: uri.lastPathSegment
|
||||||
|
|
||||||
fun openEventDetail(event: CalendarEventItem) {
|
fun openEventDetail(event: CalendarEventItem) {
|
||||||
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
|
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
|
||||||
}
|
}
|
||||||
@@ -428,6 +651,11 @@ class CalendarViewModel(
|
|||||||
eventDraft = CalendarEventDraft(
|
eventDraft = CalendarEventDraft(
|
||||||
title = event.summary,
|
title = event.summary,
|
||||||
date = start.toLocalDate(),
|
date = start.toLocalDate(),
|
||||||
|
// DTEND эксклюзивен для all-day → показываем последний включённый день.
|
||||||
|
endDate = run {
|
||||||
|
val endDay = if (event.allDay) end.toLocalDate().minusDays(1) else end.toLocalDate()
|
||||||
|
endDay.coerceAtLeast(start.toLocalDate()).takeIf { it != start.toLocalDate() }
|
||||||
|
},
|
||||||
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
|
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
|
||||||
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
|
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
|
||||||
allDay = event.allDay,
|
allDay = event.allDay,
|
||||||
@@ -440,9 +668,10 @@ class CalendarViewModel(
|
|||||||
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
|
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
|
||||||
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
|
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
|
||||||
attendees = event.attendees,
|
attendees = event.attendees,
|
||||||
reminderMinutes = event.alarms.firstOrNull()?.minutesBefore ?: 15,
|
reminders = event.alarms.map { it.minutesBefore }.ifEmpty { emptyList() },
|
||||||
talkRoomUrl = event.talkRoomUrl,
|
talkRoomUrl = event.talkRoomUrl,
|
||||||
addTalkRoom = event.talkRoomUrl.isNotBlank(),
|
addTalkRoom = event.talkRoomUrl.isNotBlank(),
|
||||||
|
attachments = event.attachments,
|
||||||
),
|
),
|
||||||
focusedEventUid = event.uid,
|
focusedEventUid = event.uid,
|
||||||
)
|
)
|
||||||
@@ -541,10 +770,10 @@ class CalendarViewModel(
|
|||||||
CalendarViewMode.LIST -> "Предстоящие события"
|
CalendarViewMode.LIST -> "Предстоящие события"
|
||||||
CalendarViewMode.WEEK -> {
|
CalendarViewMode.WEEK -> {
|
||||||
val (start, end) = CalendarRepository.weekRange(st.selectedDay)
|
val (start, end) = CalendarRepository.weekRange(st.selectedDay)
|
||||||
"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} – ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"
|
"${start.format(DateTimeFormatter.ofPattern("d MMM", RU))} – ${end.format(DateTimeFormatter.ofPattern("d MMM yyyy", RU))}"
|
||||||
}
|
}
|
||||||
CalendarViewMode.DAY, CalendarViewMode.MONTH ->
|
CalendarViewMode.DAY, CalendarViewMode.MONTH ->
|
||||||
st.selectedDay.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))
|
st.selectedDay.format(DateTimeFormatter.ofPattern("d MMMM yyyy", RU))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,6 +871,7 @@ class CalendarViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
|
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
|
||||||
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
|
private val RU: Locale = Locale.forLanguageTag("ru")
|
||||||
|
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", RU)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.calendar
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/** Разобранное правило повторения (подмножество RFC 5545 RRULE, как в веб-версии). */
|
||||||
|
data class RecurrenceRule(
|
||||||
|
val freq: String = "WEEKLY", // DAILY | WEEKLY | MONTHLY | YEARLY
|
||||||
|
val interval: Int = 1,
|
||||||
|
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
|
||||||
|
val byDays: Set<String> = emptySet(),
|
||||||
|
val endType: EndType = EndType.NEVER,
|
||||||
|
val untilDate: LocalDate? = null,
|
||||||
|
val count: Int = 10,
|
||||||
|
) {
|
||||||
|
enum class EndType { NEVER, UNTIL, COUNT }
|
||||||
|
|
||||||
|
fun toRrule(): String = buildString {
|
||||||
|
append("FREQ=$freq")
|
||||||
|
if (interval > 1) append(";INTERVAL=$interval")
|
||||||
|
if (freq == "WEEKLY" && byDays.isNotEmpty()) {
|
||||||
|
append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}")
|
||||||
|
}
|
||||||
|
when (endType) {
|
||||||
|
EndType.UNTIL -> untilDate?.let { append(";UNTIL=${it.format(UNTIL_FMT)}T235959Z") }
|
||||||
|
EndType.COUNT -> append(";COUNT=${count.coerceAtLeast(1)}")
|
||||||
|
EndType.NEVER -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val ICS_DAYS = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU")
|
||||||
|
val DAY_LABELS = mapOf(
|
||||||
|
"MO" to "Пн", "TU" to "Вт", "WE" to "Ср", "TH" to "Чт",
|
||||||
|
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
|
||||||
|
)
|
||||||
|
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
|
||||||
|
|
||||||
|
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */
|
||||||
|
fun parse(rrule: String): RecurrenceRule? {
|
||||||
|
if (rrule.isBlank()) return null
|
||||||
|
val parts = rrule.trim().removePrefix("RRULE:").split(';')
|
||||||
|
.mapNotNull { p ->
|
||||||
|
val idx = p.indexOf('=')
|
||||||
|
if (idx <= 0) null else p.substring(0, idx).uppercase() to p.substring(idx + 1)
|
||||||
|
}.toMap()
|
||||||
|
val freq = parts["FREQ"]?.uppercase() ?: return null
|
||||||
|
if (freq !in setOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY")) return null
|
||||||
|
val until = parts["UNTIL"]?.let { raw ->
|
||||||
|
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
|
||||||
|
}
|
||||||
|
val count = parts["COUNT"]?.toIntOrNull()
|
||||||
|
return RecurrenceRule(
|
||||||
|
freq = freq,
|
||||||
|
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
|
||||||
|
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() }
|
||||||
|
?.filter { it in ICS_DAYS }?.toSet().orEmpty(),
|
||||||
|
endType = when {
|
||||||
|
until != null -> EndType.UNTIL
|
||||||
|
count != null -> EndType.COUNT
|
||||||
|
else -> EndType.NEVER
|
||||||
|
},
|
||||||
|
untilDate = until,
|
||||||
|
count = count ?: 10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Человекочитаемое описание правила по-русски (для пилюли «Повторение»). */
|
||||||
|
fun describe(rrule: String): String {
|
||||||
|
val rule = parse(rrule) ?: return "Не повторять"
|
||||||
|
val freqLabel = when (rule.freq) {
|
||||||
|
"DAILY" -> if (rule.interval == 1) "Каждый день" else "Каждые ${rule.interval} дн."
|
||||||
|
"WEEKLY" -> if (rule.interval == 1) "Каждую неделю" else "Каждые ${rule.interval} нед."
|
||||||
|
"MONTHLY" -> if (rule.interval == 1) "Каждый месяц" else "Каждые ${rule.interval} мес."
|
||||||
|
else -> if (rule.interval == 1) "Каждый год" else "Каждые ${rule.interval} г."
|
||||||
|
}
|
||||||
|
val days = if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
|
||||||
|
" по " + ICS_DAYS.filter { it in rule.byDays }.joinToString(", ") { DAY_LABELS[it] ?: it }
|
||||||
|
} else ""
|
||||||
|
val end = when (rule.endType) {
|
||||||
|
EndType.UNTIL -> rule.untilDate?.let { " до ${it.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}" }.orEmpty()
|
||||||
|
EndType.COUNT -> ", ${rule.count} раз"
|
||||||
|
EndType.NEVER -> ""
|
||||||
|
}
|
||||||
|
return freqLabel + days + end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,9 +50,17 @@ class DeckRepository {
|
|||||||
|
|
||||||
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
|
||||||
val c = client(session)
|
val c = client(session)
|
||||||
val board = getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject
|
// Доска-объект (метки/права) — НЕ критична: если запрос упадёт, доску всё равно
|
||||||
val labels = parseLabels(board.optJSONArray("labels"))
|
// открываем со стеками (раньше был баг — сбой board#read рушил всю загрузку).
|
||||||
val canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
|
var boardTitle = ""
|
||||||
|
var labels = emptyList<DeckLabel>()
|
||||||
|
var canEdit = true
|
||||||
|
runCatching { getJson(c, "${apiBase(session)}/boards/$boardId") as JSONObject }
|
||||||
|
.onSuccess { board ->
|
||||||
|
boardTitle = board.optString("title")
|
||||||
|
labels = parseLabels(board.optJSONArray("labels"))
|
||||||
|
canEdit = board.optJSONObject("permissions")?.optBoolean("PERMISSION_EDIT", true) ?: true
|
||||||
|
}
|
||||||
val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
|
val stacksJson = getJson(c, "${apiBase(session)}/boards/$boardId/stacks") as? JSONArray ?: JSONArray()
|
||||||
val stacks = mutableListOf<DeckStack>()
|
val stacks = mutableListOf<DeckStack>()
|
||||||
for (i in 0 until stacksJson.length()) {
|
for (i in 0 until stacksJson.length()) {
|
||||||
@@ -74,7 +82,7 @@ class DeckRepository {
|
|||||||
}
|
}
|
||||||
return DeckBoardDetail(
|
return DeckBoardDetail(
|
||||||
boardId = boardId,
|
boardId = boardId,
|
||||||
title = board.optString("title"),
|
title = boardTitle,
|
||||||
canEdit = canEdit,
|
canEdit = canEdit,
|
||||||
labels = labels,
|
labels = labels,
|
||||||
stacks = stacks.sortedBy { it.order },
|
stacks = stacks.sortedBy { it.order },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items as gridItems
|
import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
@@ -113,6 +114,7 @@ fun FilesScreen(
|
|||||||
|
|
||||||
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
|
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
|
||||||
OfficeWarmup.warm(session)
|
OfficeWarmup.warm(session)
|
||||||
|
OfficeWarmup.warmWebViewEngine(context) // прогрев WebView-движка для быстрого docx/xlsx
|
||||||
vm.load(session)
|
vm.load(session)
|
||||||
vm.loadSidebarData(session)
|
vm.loadSidebarData(session)
|
||||||
}
|
}
|
||||||
@@ -241,7 +243,12 @@ fun FilesScreen(
|
|||||||
) {
|
) {
|
||||||
val openItem: (FileItem) -> Unit = { item ->
|
val openItem: (FileItem) -> Unit = { item ->
|
||||||
val openable = !item.isDirectory &&
|
val openable = !item.isDirectory &&
|
||||||
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
|
(
|
||||||
|
OfficeFiles.isOfficeFile(item.name) ||
|
||||||
|
MarkdownFiles.isMarkdown(item.name) ||
|
||||||
|
WebOpenableFiles.isWebOpenable(item.name) ||
|
||||||
|
OpenableFiles.isOpenable(item.name)
|
||||||
|
)
|
||||||
when {
|
when {
|
||||||
item.isDirectory -> vm.openFolder(session, item)
|
item.isDirectory -> vm.openFolder(session, item)
|
||||||
openable -> vm.openItem(session, item)
|
openable -> vm.openItem(session, item)
|
||||||
@@ -472,4 +479,14 @@ fun FilesScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Нативный редактор markdown/текста поверх всего (быстрый, вместо тяжёлого веб-Text)
|
||||||
|
state.markdownDoc?.let { doc ->
|
||||||
|
BackHandler { vm.closeMarkdown() }
|
||||||
|
MarkdownEditorScreen(
|
||||||
|
session = session,
|
||||||
|
doc = doc,
|
||||||
|
onClose = { vm.closeMarkdown() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ data class FilesUiState(
|
|||||||
val openingFile: String? = null,
|
val openingFile: String? = null,
|
||||||
val editorLaunch: OfficeEditorLaunch? = null,
|
val editorLaunch: OfficeEditorLaunch? = null,
|
||||||
val openAction: FileOpenAction? = null,
|
val openAction: FileOpenAction? = null,
|
||||||
|
// Нативный markdown-редактор: открытый текстовый файл (путь + имя).
|
||||||
|
val markdownDoc: MarkdownDoc? = null,
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
val searchResults: List<FilesSearchHit> = emptyList(),
|
val searchResults: List<FilesSearchHit> = emptyList(),
|
||||||
val searchActive: Boolean = false,
|
val searchActive: Boolean = false,
|
||||||
|
|||||||
@@ -487,6 +487,8 @@ class FilesViewModel(
|
|||||||
if (item.isDirectory) return
|
if (item.isDirectory) return
|
||||||
when {
|
when {
|
||||||
OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item)
|
OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item)
|
||||||
|
MarkdownFiles.isMarkdown(item.name) -> openMarkdown(item)
|
||||||
|
WebOpenableFiles.isWebOpenable(item.name) -> openWebFile(session, item)
|
||||||
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
|
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
|
||||||
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
|
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
|
||||||
else -> _state.value = _state.value.copy(
|
else -> _state.value = _state.value.copy(
|
||||||
@@ -495,6 +497,37 @@ class FilesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Markdown/текст — нативный редактор (мгновенно, WebDAV). */
|
||||||
|
private fun openMarkdown(item: FileItem) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
markdownDoc = MarkdownDoc(relativePath = item.relativePath, name = item.name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeMarkdown() {
|
||||||
|
_state.value = _state.value.copy(markdownDoc = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown/текст/доска — открываем каноническую ссылку /f/<id> в авторизованном WebView. */
|
||||||
|
private fun openWebFile(session: AuthSession, item: FileItem) {
|
||||||
|
val fileId = item.fileId ?: run {
|
||||||
|
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val base = session.serverUrl.trimEnd('/')
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
editorLaunch = OfficeEditorLaunch(
|
||||||
|
url = "$base/index.php/f/$fileId",
|
||||||
|
title = item.name,
|
||||||
|
username = session.username,
|
||||||
|
password = session.appPassword,
|
||||||
|
trustAllCerts = session.trustAllCerts,
|
||||||
|
serverUrl = session.serverUrl,
|
||||||
|
collaboraBaseUrl = "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun openOfficeFile(session: AuthSession, item: FileItem) {
|
private fun openOfficeFile(session: AuthSession, item: FileItem) {
|
||||||
val fileId = item.fileId ?: run {
|
val fileId = item.fileId ?: run {
|
||||||
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
|
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.files
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.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.SolidColor
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
|
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нативный редактор markdown/текста: качает содержимое по WebDAV (мгновенно), правка в
|
||||||
|
* TextField, предпросмотр с базовым рендером, сохранение по WebDAV PUT. Заменяет тяжёлый
|
||||||
|
* веб-редактор Text (который «вечно грузился», т.к. тянул весь Files UI).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MarkdownEditorScreen(
|
||||||
|
session: AuthSession,
|
||||||
|
doc: MarkdownDoc,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
repository: MarkdownRepository = remember { MarkdownRepository() },
|
||||||
|
) {
|
||||||
|
var text by remember(doc.relativePath) { mutableStateOf("") }
|
||||||
|
var loading by remember(doc.relativePath) { mutableStateOf(true) }
|
||||||
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var preview by remember { mutableStateOf(false) }
|
||||||
|
var dirty by remember(doc.relativePath) { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val isMd = doc.name.substringAfterLast('.', "").lowercase() in setOf("md", "markdown")
|
||||||
|
|
||||||
|
LaunchedEffect(doc.relativePath) {
|
||||||
|
loading = true
|
||||||
|
error = null
|
||||||
|
runCatching { withContext(Dispatchers.IO) { repository.load(session, doc.relativePath) } }
|
||||||
|
.onSuccess { text = it; loading = false }
|
||||||
|
.onFailure { error = it.message ?: "Ошибка загрузки"; loading = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(F7Colors.Background)
|
||||||
|
.f7SafeTopInsets(),
|
||||||
|
) {
|
||||||
|
// Шапка: назад, имя, предпросмотр (для md), сохранить
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Назад",
|
||||||
|
tint = F7Colors.TextPrimary,
|
||||||
|
modifier = Modifier.size(24.dp).clickable(onClick = onClose),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
doc.name,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
if (isMd) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(100.dp))
|
||||||
|
.background(if (preview) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
|
||||||
|
.clickable { preview = !preview }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (preview) "Правка" else "Просмотр",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(100.dp))
|
||||||
|
.background(if (dirty && !saving) F7Colors.Primary else F7Colors.SurfaceMuted)
|
||||||
|
.clickable(enabled = dirty && !saving) {
|
||||||
|
saving = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) { repository.save(session, doc.relativePath, text) }
|
||||||
|
}.onSuccess { saving = false; dirty = false }
|
||||||
|
.onFailure { error = it.message ?: "Ошибка сохранения"; saving = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (saving) "…" else "Сохранить",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (dirty && !saving) androidx.compose.ui.graphics.Color.White else F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error?.let {
|
||||||
|
Text(it, color = F7Colors.Error, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = F7Colors.Primary)
|
||||||
|
}
|
||||||
|
preview && isMd -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(renderMarkdown(text), style = MaterialTheme.typography.bodyLarge, color = F7Colors.TextPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
BasicTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it; dirty = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
fontFamily = if (isMd) FontFamily.Monospace else FontFamily.Default,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(F7Colors.Primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лёгкий рендер markdown → AnnotatedString: заголовки, жирный/курсив, списки, код, цитаты. */
|
||||||
|
private fun renderMarkdown(src: String): AnnotatedString = buildAnnotatedString {
|
||||||
|
src.lineSequence().forEachIndexed { index, raw ->
|
||||||
|
if (index > 0) append("\n")
|
||||||
|
val line = raw.trimEnd()
|
||||||
|
when {
|
||||||
|
line.startsWith("### ") -> withStyle(SpanStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold)) {
|
||||||
|
append(line.removePrefix("### "))
|
||||||
|
}
|
||||||
|
line.startsWith("## ") -> withStyle(SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold)) {
|
||||||
|
append(line.removePrefix("## "))
|
||||||
|
}
|
||||||
|
line.startsWith("# ") -> withStyle(SpanStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold)) {
|
||||||
|
append(line.removePrefix("# "))
|
||||||
|
}
|
||||||
|
line.startsWith("> ") -> withStyle(
|
||||||
|
SpanStyle(fontStyle = FontStyle.Italic, color = androidx.compose.ui.graphics.Color(0xFF808080)),
|
||||||
|
) { append(line.removePrefix("> ")) }
|
||||||
|
line.startsWith("- ") || line.startsWith("* ") -> {
|
||||||
|
append("• ")
|
||||||
|
appendInline(line.drop(2))
|
||||||
|
}
|
||||||
|
line.matches(Regex("^\\d+\\. .*")) -> appendInline(line)
|
||||||
|
line.startsWith("```") -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(line) }
|
||||||
|
else -> appendInline(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Инлайн-разметка: **жирный**, *курсив*, `код`. */
|
||||||
|
private fun androidx.compose.ui.text.AnnotatedString.Builder.appendInline(text: String) {
|
||||||
|
var i = 0
|
||||||
|
while (i < text.length) {
|
||||||
|
when {
|
||||||
|
text.startsWith("**", i) -> {
|
||||||
|
val end = text.indexOf("**", i + 2)
|
||||||
|
if (end > 0) {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }
|
||||||
|
i = end + 2
|
||||||
|
} else { append(text[i]); i++ }
|
||||||
|
}
|
||||||
|
text[i] == '*' -> {
|
||||||
|
val end = text.indexOf('*', i + 1)
|
||||||
|
if (end > 0) {
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
|
||||||
|
i = end + 1
|
||||||
|
} else { append(text[i]); i++ }
|
||||||
|
}
|
||||||
|
text[i] == '`' -> {
|
||||||
|
val end = text.indexOf('`', i + 1)
|
||||||
|
if (end > 0) {
|
||||||
|
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }
|
||||||
|
i = end + 1
|
||||||
|
} else { append(text[i]); i++ }
|
||||||
|
}
|
||||||
|
else -> { append(text[i]); i++ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.files
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||||
|
import ru.forbion.f7cloud.core.auth.OcsUserResolver
|
||||||
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.davFileUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нативная работа с текстовыми/markdown-файлами по WebDAV — быстро, без тяжёлого веб-редактора
|
||||||
|
* (веб Text грузил весь Files UI). Просто GET текста и PUT при сохранении.
|
||||||
|
*/
|
||||||
|
class MarkdownRepository {
|
||||||
|
private fun client(session: AuthSession) =
|
||||||
|
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
|
||||||
|
|
||||||
|
private fun url(session: AuthSession, relativePath: String): String {
|
||||||
|
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||||
|
return davFileUrl(session.serverUrl, userId, relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(session: AuthSession, relativePath: String): String {
|
||||||
|
val request = Request.Builder().url(url(session, relativePath)).header("Accept", "*/*").get().build()
|
||||||
|
client(session).newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
error("Не удалось загрузить файл (HTTP ${response.code})")
|
||||||
|
}
|
||||||
|
return response.body!!.string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(session: AuthSession, relativePath: String, content: String) {
|
||||||
|
val media = "text/markdown; charset=utf-8".toMediaType()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url(session, relativePath))
|
||||||
|
.put(content.toRequestBody(media))
|
||||||
|
.build()
|
||||||
|
client(session).newCall(request).execute().use { response ->
|
||||||
|
if (response.code !in 200..299 && response.code != 204) {
|
||||||
|
error("Не удалось сохранить (HTTP ${response.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,23 @@ object OfficeWarmup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var engineWarmed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогрев WebView-движка: ПЕРВОЕ создание WebView в процессе тянет провайдер Chromium
|
||||||
|
* (сотни мс), из-за чего первый docx/xlsx открывается медленно. Создаём и сразу уничтожаем
|
||||||
|
* пустой WebView заранее (при входе в Файлы) — движок загружается в фоне. Только UI-поток.
|
||||||
|
*/
|
||||||
|
fun warmWebViewEngine(context: android.content.Context) {
|
||||||
|
if (engineWarmed) return
|
||||||
|
engineWarmed = true
|
||||||
|
runCatching {
|
||||||
|
val wv = android.webkit.WebView(context.applicationContext)
|
||||||
|
wv.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
cachedServerUrl = null
|
cachedServerUrl = null
|
||||||
cachedCollaboraUrl = null
|
cachedCollaboraUrl = null
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.files
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файлы, открываемые во ВЕБ-вьюере Nextcloud по ссылке `/index.php/f/<fileId>` — сервер
|
||||||
|
* направляет в нужное приложение. Сейчас это только доска (Whiteboard, Excalidraw-канвас
|
||||||
|
* с реалтайм-бэкендом — нативно не реализуется). Markdown/текст — нативно, см. [MarkdownFiles].
|
||||||
|
*/
|
||||||
|
object WebOpenableFiles {
|
||||||
|
private val EXTENSIONS = setOf("whiteboard")
|
||||||
|
|
||||||
|
fun isWebOpenable(name: String): Boolean =
|
||||||
|
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текстовые/markdown-файлы — открываются в НАТИВНОМ редакторе (быстро, WebDAV). */
|
||||||
|
object MarkdownFiles {
|
||||||
|
private val EXTENSIONS = setOf("md", "markdown", "txt", "text", "org", "log")
|
||||||
|
|
||||||
|
fun isMarkdown(name: String): Boolean =
|
||||||
|
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Открытый в нативном редакторе текстовый файл. */
|
||||||
|
data class MarkdownDoc(
|
||||||
|
val relativePath: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ru.forbion.f7cloud.feature.tasks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лёгкий кэш в памяти на время жизни процесса: списки задач и задачи по каждому списку.
|
||||||
|
* VM пересоздаётся при каждом открытии раздела Задач — без кэша это полный PROPFIND+REPORT
|
||||||
|
* заново. Кэш даёт мгновенный показ, сеть обновляет в фоне.
|
||||||
|
*/
|
||||||
|
internal object TasksCache {
|
||||||
|
@Volatile
|
||||||
|
private var lists: List<TaskListItem>? = null
|
||||||
|
private val tasksByList = java.util.concurrent.ConcurrentHashMap<String, List<TaskItem>>()
|
||||||
|
|
||||||
|
fun cachedLists(): List<TaskListItem>? = lists
|
||||||
|
|
||||||
|
fun putLists(value: List<TaskListItem>) {
|
||||||
|
lists = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cachedTasks(listHref: String): List<TaskItem>? = tasksByList[listHref]
|
||||||
|
|
||||||
|
fun putTasks(listHref: String, value: List<TaskItem>) {
|
||||||
|
tasksByList[listHref] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateTasks(listHref: String) {
|
||||||
|
tasksByList.remove(listHref)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.animateContentSize
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -345,7 +346,10 @@ fun TasksRow(
|
|||||||
onToggleComplete: () -> Unit,
|
onToggleComplete: () -> Unit,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onAddSubtask: (() -> Unit)? = null,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
val hasMeta = showMeta && (task.listName.isNotBlank() || task.percentComplete in 1..99 || task.due.isNotBlank())
|
val hasMeta = showMeta && (task.listName.isNotBlank() || task.percentComplete in 1..99 || task.due.isNotBlank())
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -401,6 +405,43 @@ fun TasksRow(
|
|||||||
color = task.dueDateColor(),
|
color = task.dueDateColor(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Меню «⋯»: вложенная задача / удалить (как в веб-версии)
|
||||||
|
if (onAddSubtask != null || onDelete != null) {
|
||||||
|
Box {
|
||||||
|
Text(
|
||||||
|
"⋯",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = { menuOpen = true },
|
||||||
|
),
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
)
|
||||||
|
androidx.compose.material3.DropdownMenu(
|
||||||
|
expanded = menuOpen,
|
||||||
|
onDismissRequest = { menuOpen = false },
|
||||||
|
containerColor = androidx.compose.ui.graphics.Color.White,
|
||||||
|
) {
|
||||||
|
onAddSubtask?.let { add ->
|
||||||
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
|
text = { Text("Добавить вложенную задачу", color = F7Colors.TextPrimary) },
|
||||||
|
onClick = { menuOpen = false; add() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDelete?.let { del ->
|
||||||
|
androidx.compose.material3.DropdownMenuItem(
|
||||||
|
text = { Text("Удалить задачу", color = F7Colors.Error) },
|
||||||
|
onClick = { menuOpen = false; del() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasMeta) {
|
if (hasMeta) {
|
||||||
@@ -1064,3 +1105,42 @@ private fun SmartListSettingRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Диалог создания вложенной задачи (подзадачи) — по кнопке «Добавить вложенную задачу». */
|
||||||
|
@Composable
|
||||||
|
fun TaskSubtaskDialog(
|
||||||
|
parentTitle: String,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"Вложенная задача",
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"к «$parentTitle»",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
F7OutlinedField(value = text, onValueChange = { text = it }, label = "Название")
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||||
|
F7PrimaryButton(
|
||||||
|
text = "Создать",
|
||||||
|
onClick = { onConfirm(text.trim()) },
|
||||||
|
enabled = text.isNotBlank(),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ data class TaskItem(
|
|||||||
val description: String,
|
val description: String,
|
||||||
val listHref: String,
|
val listHref: String,
|
||||||
val listName: String,
|
val listName: String,
|
||||||
|
val parentUid: String = "",
|
||||||
) {
|
) {
|
||||||
val isCompleted: Boolean
|
val isCompleted: Boolean
|
||||||
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
|
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
|
||||||
@@ -46,6 +47,7 @@ data class TaskItem(
|
|||||||
description = description,
|
description = description,
|
||||||
calendarName = listName,
|
calendarName = listName,
|
||||||
calendarHref = listHref,
|
calendarHref = listHref,
|
||||||
|
parentUid = parentUid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ class TasksRepository {
|
|||||||
dueDate: LocalDate? = null,
|
dueDate: LocalDate? = null,
|
||||||
priority: Int = 0,
|
priority: Int = 0,
|
||||||
description: String = "",
|
description: String = "",
|
||||||
|
parentUid: String = "",
|
||||||
): TaskItem {
|
): TaskItem {
|
||||||
val client = authedClient(session)
|
val client = authedClient(session)
|
||||||
val calendar = calendarForHref(session, listHref)
|
val calendar = calendarForHref(session, listHref)
|
||||||
@@ -90,6 +93,7 @@ class TasksRepository {
|
|||||||
dueDate = dueDate,
|
dueDate = dueDate,
|
||||||
priority = priority,
|
priority = priority,
|
||||||
description = description,
|
description = description,
|
||||||
|
parentUid = parentUid,
|
||||||
).toTaskItem()
|
).toTaskItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +161,7 @@ class TasksRepository {
|
|||||||
description = description,
|
description = description,
|
||||||
listHref = calendarHref,
|
listHref = calendarHref,
|
||||||
listName = calendarName,
|
listName = calendarName,
|
||||||
|
parentUid = parentUid,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun calendarSlug(href: String): String {
|
private fun calendarSlug(href: String): String {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -31,11 +34,19 @@ fun TasksScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
createRequest: Int = 0,
|
createRequest: Int = 0,
|
||||||
openListSlug: String? = null,
|
openListSlug: String? = null,
|
||||||
|
showListsRequest: Int = 0,
|
||||||
onOpenListConsumed: () -> Unit = {},
|
onOpenListConsumed: () -> Unit = {},
|
||||||
onUnauthorized: () -> Unit = {},
|
onUnauthorized: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val vm: TasksViewModel = viewModel()
|
val vm: TasksViewModel = viewModel()
|
||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
|
// Родитель для создаваемой подзадачи (открывает диалог ввода названия).
|
||||||
|
var subtaskParent by remember { mutableStateOf<TaskItem?>(null) }
|
||||||
|
|
||||||
|
// Стрелка «назад» нижней панели на Задачах → показать панель списков (закрыть открытый список).
|
||||||
|
LaunchedEffect(showListsRequest) {
|
||||||
|
if (showListsRequest > 0) vm.closeList()
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
|
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
|
||||||
vm.load(session)
|
vm.load(session)
|
||||||
@@ -103,6 +114,8 @@ fun TasksScreen(
|
|||||||
onReload = { vm.refreshTasks(session) },
|
onReload = { vm.refreshTasks(session) },
|
||||||
onToggleCompletedExpanded = vm::toggleCompletedExpanded,
|
onToggleCompletedExpanded = vm::toggleCompletedExpanded,
|
||||||
onDeleteCompleted = { vm.deleteCompletedTasks(session) },
|
onDeleteCompleted = { vm.deleteCompletedTasks(session) },
|
||||||
|
onAddSubtask = { subtaskParent = it },
|
||||||
|
onDeleteTask = { vm.deleteTask(session, it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,6 +156,18 @@ fun TasksScreen(
|
|||||||
onDismiss = vm::closeSettings,
|
onDismiss = vm::closeSettings,
|
||||||
onSwipeBack = vm::closeSettings,
|
onSwipeBack = vm::closeSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Диалог «Добавить вложенную задачу»
|
||||||
|
subtaskParent?.let { parent ->
|
||||||
|
TaskSubtaskDialog(
|
||||||
|
parentTitle = parent.summary,
|
||||||
|
onConfirm = { title ->
|
||||||
|
vm.createSubtask(session, parent, title)
|
||||||
|
subtaskParent = null
|
||||||
|
},
|
||||||
|
onDismiss = { subtaskParent = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -162,7 +187,15 @@ private fun TasksMainContent(
|
|||||||
onReload: () -> Unit,
|
onReload: () -> Unit,
|
||||||
onToggleCompletedExpanded: () -> Unit,
|
onToggleCompletedExpanded: () -> Unit,
|
||||||
onDeleteCompleted: () -> Unit,
|
onDeleteCompleted: () -> Unit,
|
||||||
|
onAddSubtask: (TaskItem) -> Unit,
|
||||||
|
onDeleteTask: (TaskItem) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Подзадачи (RELATED-TO) — рисуем под родителем с отступом; из верхнего уровня исключаем.
|
||||||
|
val childrenByParent = remember(state.visibleActiveGroups) {
|
||||||
|
state.visibleActiveGroups.flatMap { it.tasks }
|
||||||
|
.filter { it.parentUid.isNotBlank() }
|
||||||
|
.groupBy { it.parentUid }
|
||||||
|
}
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
TasksToolbar(
|
TasksToolbar(
|
||||||
serverUrl = session.serverUrl,
|
serverUrl = session.serverUrl,
|
||||||
@@ -212,13 +245,27 @@ private fun TasksMainContent(
|
|||||||
modifier = Modifier.padding(bottom = 4.dp),
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(group.tasks, key = { it.uid }) { task ->
|
items(group.tasks.filter { it.parentUid.isBlank() }, key = { it.uid }) { task ->
|
||||||
TasksRow(
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
task = task,
|
TasksRow(
|
||||||
serverUrl = session.serverUrl,
|
task = task,
|
||||||
onToggleComplete = { onToggleComplete(task) },
|
serverUrl = session.serverUrl,
|
||||||
onOpen = { onOpenTask(task) },
|
onToggleComplete = { onToggleComplete(task) },
|
||||||
)
|
onOpen = { onOpenTask(task) },
|
||||||
|
onAddSubtask = { onAddSubtask(task) },
|
||||||
|
onDelete = { onDeleteTask(task) },
|
||||||
|
)
|
||||||
|
childrenByParent[task.uid].orEmpty().forEach { sub ->
|
||||||
|
TasksRow(
|
||||||
|
task = sub,
|
||||||
|
serverUrl = session.serverUrl,
|
||||||
|
onToggleComplete = { onToggleComplete(sub) },
|
||||||
|
onOpen = { onOpenTask(sub) },
|
||||||
|
onDelete = { onDeleteTask(sub) },
|
||||||
|
modifier = Modifier.padding(start = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,10 +74,27 @@ class TasksViewModel(
|
|||||||
val state: StateFlow<TasksUiState> = _state.asStateFlow()
|
val state: StateFlow<TasksUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
fun load(session: AuthSession) {
|
fun load(session: AuthSession) {
|
||||||
|
// Мгновенно показываем кэш (списки + задачи выбранного), сеть обновляет в фоне.
|
||||||
|
val cachedLists = TasksCache.cachedLists()
|
||||||
|
if (cachedLists != null && cachedLists.isNotEmpty()) {
|
||||||
|
val selected = _state.value.selectedListHref
|
||||||
|
?: _state.value.defaultListHref
|
||||||
|
?: cachedLists.firstOrNull()?.href
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loading = false,
|
||||||
|
lists = cachedLists,
|
||||||
|
selectedListHref = selected,
|
||||||
|
defaultListHref = it.defaultListHref ?: cachedLists.firstOrNull()?.href,
|
||||||
|
tasks = selected?.let { href -> TasksCache.cachedTasks(href) } ?: it.tasks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
_state.update { it.copy(loading = it.lists.isEmpty(), error = null) }
|
_state.update { it.copy(loading = it.lists.isEmpty(), error = null) }
|
||||||
runCatching { repository.listTaskLists(session) }
|
runCatching { repository.listTaskLists(session) }
|
||||||
.onSuccess { lists ->
|
.onSuccess { lists ->
|
||||||
|
TasksCache.putLists(lists)
|
||||||
val selected = _state.value.selectedListHref
|
val selected = _state.value.selectedListHref
|
||||||
?: _state.value.defaultListHref
|
?: _state.value.defaultListHref
|
||||||
?: lists.firstOrNull()?.href
|
?: lists.firstOrNull()?.href
|
||||||
@@ -107,6 +124,8 @@ class TasksViewModel(
|
|||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedListHref = listHref,
|
selectedListHref = listHref,
|
||||||
|
// Мгновенно из кэша, если есть — иначе пусто до загрузки.
|
||||||
|
tasks = TasksCache.cachedTasks(listHref).orEmpty(),
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
detailTask = null,
|
detailTask = null,
|
||||||
createInputExpanded = false,
|
createInputExpanded = false,
|
||||||
@@ -197,6 +216,7 @@ class TasksViewModel(
|
|||||||
summary: String,
|
summary: String,
|
||||||
dueInput: String,
|
dueInput: String,
|
||||||
priority: Int,
|
priority: Int,
|
||||||
|
parentUid: String = "",
|
||||||
) {
|
) {
|
||||||
val listHref = _state.value.selectedListHref ?: return
|
val listHref = _state.value.selectedListHref ?: return
|
||||||
if (summary.isBlank()) {
|
if (summary.isBlank()) {
|
||||||
@@ -218,6 +238,7 @@ class TasksViewModel(
|
|||||||
summary = summary.trim(),
|
summary = summary.trim(),
|
||||||
dueDate = dueDate,
|
dueDate = dueDate,
|
||||||
priority = priority,
|
priority = priority,
|
||||||
|
parentUid = parentUid,
|
||||||
)
|
)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_state.update {
|
_state.update {
|
||||||
@@ -301,6 +322,30 @@ class TasksViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Удаление конкретной задачи из списка (per-task меню «⋯»), не только открытой детали. */
|
||||||
|
fun deleteTask(session: AuthSession, task: TaskItem) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_state.update { it.copy(saving = true, error = null) }
|
||||||
|
runCatching { repository.deleteTask(session, task) }
|
||||||
|
.onSuccess {
|
||||||
|
_state.update {
|
||||||
|
it.copy(saving = false, detailTask = it.detailTask?.takeIf { d -> d.uid != task.uid })
|
||||||
|
}
|
||||||
|
_state.value.selectedListHref?.let { loadTasks(session, it) }
|
||||||
|
}
|
||||||
|
.onFailure { t ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(saving = false, error = t.message ?: "Не удалось удалить", unauthorized = t is UnauthorizedException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создать подзадачу к [parent] с заголовком [summary]. */
|
||||||
|
fun createSubtask(session: AuthSession, parent: TaskItem, summary: String) {
|
||||||
|
createTask(session, summary, "", 0, parentUid = parent.uid)
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteDetail(session: AuthSession) {
|
fun deleteDetail(session: AuthSession) {
|
||||||
val task = _state.value.detailTask ?: return
|
val task = _state.value.detailTask ?: return
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@@ -355,7 +400,11 @@ class TasksViewModel(
|
|||||||
_state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) }
|
_state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) }
|
||||||
runCatching { repository.loadTasks(session, listHref) }
|
runCatching { repository.loadTasks(session, listHref) }
|
||||||
.onSuccess { tasks ->
|
.onSuccess { tasks ->
|
||||||
_state.update { it.copy(loading = false, tasks = tasks) }
|
TasksCache.putTasks(listHref, tasks)
|
||||||
|
// Обновляем список задач, только если пользователь всё ещё на этом списке.
|
||||||
|
if (_state.value.selectedListHref == listHref) {
|
||||||
|
_state.update { it.copy(loading = false, tasks = tasks) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onFailure { t ->
|
.onFailure { t ->
|
||||||
_state.update {
|
_state.update {
|
||||||
|
|||||||