Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91aaaba5a0 | |||
| e6a71de7a6 | |||
| 4b000d3b27 | |||
| f70b1861bd | |||
| 6cdef16952 | |||
| c7af5f7d11 | |||
| 2ec2115be7 | |||
| 3e5d0d4892 | |||
| c0703c3537 |
@@ -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 не тестировались на устройстве — проверить создание/правку/перемещение
|
||||||
|
|||||||
+2
-2
@@ -39,8 +39,8 @@ android {
|
|||||||
applicationId 'ru.forbion.f7cloud.mobile'
|
applicationId 'ru.forbion.f7cloud.mobile'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 142
|
versionCode 152
|
||||||
versionName '0.5.134'
|
versionName '0.5.144'
|
||||||
missingDimensionStrategy 'default', 'f7'
|
missingDimensionStrategy 'default', 'f7'
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
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)
|
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 {
|
||||||
|
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||||
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -112,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)
|
||||||
@@ -298,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,
|
||||||
@@ -339,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,
|
||||||
@@ -593,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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,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>()
|
||||||
@@ -809,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()) {
|
||||||
@@ -819,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()
|
||||||
@@ -837,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+112
@@ -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"
|
||||||
|
|||||||
+32
-21
@@ -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(),
|
// падала при открытии, а платформенный диалог стабилен и сам локализуется под системную
|
||||||
|
// локаль (русские месяцы). Показываем один раз, пока композабл присутствует в дереве.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentOnConfirm by rememberUpdatedState(onConfirm)
|
||||||
|
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val dialog = android.app.DatePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, year, month, dayOfMonth ->
|
||||||
|
currentOnConfirm(LocalDate.of(year, month + 1, dayOfMonth))
|
||||||
|
},
|
||||||
|
selectedDay.year,
|
||||||
|
selectedDay.monthValue - 1,
|
||||||
|
selectedDay.dayOfMonth,
|
||||||
)
|
)
|
||||||
DatePickerDialog(
|
// onDismiss закрывает и по «Отмена», и после выбора (диалог сам себя закрывает).
|
||||||
onDismissRequest = onDismiss,
|
dialog.setOnDismissListener { currentOnDismiss() }
|
||||||
confirmButton = {
|
dialog.show()
|
||||||
TextButton(onClick = {
|
onDispose { dialog.setOnDismissListener(null); dialog.dismiss() }
|
||||||
val millis = state.selectedDateMillis ?: return@TextButton
|
|
||||||
val day = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
|
|
||||||
onConfirm(day)
|
|
||||||
}) { Text("OK") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text("Отмена") }
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
DatePicker(state = state)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1111
-67
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -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(
|
||||||
|
|||||||
+122
-8
@@ -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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+235
-5
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -244,6 +245,7 @@ fun FilesScreen(
|
|||||||
val openable = !item.isDirectory &&
|
val openable = !item.isDirectory &&
|
||||||
(
|
(
|
||||||
OfficeFiles.isOfficeFile(item.name) ||
|
OfficeFiles.isOfficeFile(item.name) ||
|
||||||
|
MarkdownFiles.isMarkdown(item.name) ||
|
||||||
WebOpenableFiles.isWebOpenable(item.name) ||
|
WebOpenableFiles.isWebOpenable(item.name) ||
|
||||||
OpenableFiles.isOpenable(item.name)
|
OpenableFiles.isOpenable(item.name)
|
||||||
)
|
)
|
||||||
@@ -477,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,7 @@ 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)
|
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)
|
||||||
@@ -496,6 +497,17 @@ 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. */
|
/** Markdown/текст/доска — открываем каноническую ссылку /f/<id> в авторизованном WebView. */
|
||||||
private fun openWebFile(session: AuthSession, item: FileItem) {
|
private fun openWebFile(session: AuthSession, item: FileItem) {
|
||||||
val fileId = item.fileId ?: run {
|
val fileId = item.fileId ?: run {
|
||||||
|
|||||||
@@ -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})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
package ru.forbion.f7cloud.feature.files
|
package ru.forbion.f7cloud.feature.files
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Файлы, которые открываются во ВЕБ-вьюере Nextcloud по канонической ссылке
|
* Файлы, открываемые во ВЕБ-вьюере Nextcloud по ссылке `/index.php/f/<fileId>` — сервер
|
||||||
* `/index.php/f/<fileId>` (сервер сам направляет в нужное приложение):
|
* направляет в нужное приложение. Сейчас это только доска (Whiteboard, Excalidraw-канвас
|
||||||
* markdown/текст → Text, доска → Whiteboard. В отличие от office (Collabora direct-edit).
|
* с реалтайм-бэкендом — нативно не реализуется). Markdown/текст — нативно, см. [MarkdownFiles].
|
||||||
*/
|
*/
|
||||||
object WebOpenableFiles {
|
object WebOpenableFiles {
|
||||||
private val EXTENSIONS = setOf(
|
private val EXTENSIONS = setOf("whiteboard")
|
||||||
"md", "markdown", "txt", "text", "org",
|
|
||||||
"whiteboard",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun isWebOpenable(name: String): Boolean =
|
fun isWebOpenable(name: String): Boolean =
|
||||||
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
|
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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user