Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91aaaba5a0 | |||
| e6a71de7a6 | |||
| 4b000d3b27 | |||
| f70b1861bd | |||
| 6cdef16952 |
@@ -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.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'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 144
|
||||
versionName '0.5.136'
|
||||
versionCode 152
|
||||
versionName '0.5.144'
|
||||
missingDimensionStrategy 'default', 'f7'
|
||||
multiDexEnabled true
|
||||
|
||||
|
||||
@@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.NextcloudWebSession
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
|
||||
class OfficeEditorActivity : ComponentActivity() {
|
||||
@@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() {
|
||||
if (current == next) return
|
||||
editorVisible = false
|
||||
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)
|
||||
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? {
|
||||
@@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() {
|
||||
val current = webView.url?.trimEnd('/').orEmpty()
|
||||
val target = launch.url.trimEnd('/')
|
||||
if (current != target) {
|
||||
webView.loadUrl(launch.url)
|
||||
startLoad(webView, launch)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -346,10 +399,11 @@ class OfficeEditorActivity : ComponentActivity() {
|
||||
},
|
||||
onPageReady = { view ->
|
||||
runOnUiThread {
|
||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||
view?.evaluateJavascript(HIDE_F7CLOUD_CHROME_JS, null)
|
||||
// Web-файлы (whiteboard и пр.): Collabora-JS-моста нет, готовность = загрузка
|
||||
// страницы. Иначе всегда срабатывал таймаут «редактор не ответил вовремя».
|
||||
if (launch.collaboraBaseUrl.isBlank()) {
|
||||
} else {
|
||||
// Web-файлы (whiteboard и пр.): Collabora-моста нет — готовность = загрузка
|
||||
// страницы; chrome НЕ прячем (иначе снесётся весь контейнер приложения).
|
||||
editorVisible = true
|
||||
loadError = null
|
||||
}
|
||||
|
||||
@@ -23,12 +23,17 @@ internal class OfficeWebViewClient(
|
||||
private val onPageReady: (WebView?) -> Unit,
|
||||
) : 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?) {
|
||||
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?) {
|
||||
view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
if (hideChrome) view?.evaluateJavascript(OfficeEditorActivity.HIDE_F7CLOUD_CHROME_JS, null)
|
||||
onPageReady(view)
|
||||
}
|
||||
|
||||
@@ -123,11 +128,10 @@ internal class OfficeWebViewClient(
|
||||
}
|
||||
|
||||
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
|
||||
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/'))
|
||||
if (launch.collaboraBaseUrl.isNotBlank()) {
|
||||
prefixes += launch.collaboraBaseUrl.trimEnd('/')
|
||||
}
|
||||
return prefixes.distinct()
|
||||
// Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии
|
||||
// (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files.
|
||||
if (launch.collaboraBaseUrl.isBlank()) return emptyList()
|
||||
return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ data class DavCalendar(
|
||||
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||
val ctag: String = "",
|
||||
// supported-calendar-component-set: поддерживает ли коллекция VEVENT. VTODO-only списки
|
||||
// задач не принимают события → PUT VEVENT туда возвращает 403. По умолчанию true (если
|
||||
// сервер не вернул свойство — не ломаем поведение).
|
||||
val supportsEvents: Boolean = true,
|
||||
)
|
||||
|
||||
data class DavEvent(
|
||||
@@ -50,6 +54,7 @@ data class DavEvent(
|
||||
val organizerName: String = "",
|
||||
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||
val conferenceUri: String = "",
|
||||
val attachments: List<CalendarAttachmentData> = emptyList(),
|
||||
)
|
||||
|
||||
data class DavTrashEvent(
|
||||
@@ -112,8 +117,8 @@ object CalDavClient {
|
||||
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
|
||||
val body = """
|
||||
<?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:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
|
||||
<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/><c:supported-calendar-component-set/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||
@@ -298,11 +303,27 @@ object CalDavClient {
|
||||
}
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
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(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String,
|
||||
@@ -339,6 +360,122 @@ object CalDavClient {
|
||||
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(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String,
|
||||
@@ -593,6 +730,7 @@ object CalDavClient {
|
||||
organizerName = parsed.organizerName,
|
||||
alarms = parsed.alarms,
|
||||
conferenceUri = parsed.conferenceUri,
|
||||
attachments = parsed.attachments,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -799,6 +937,99 @@ object CalDavClient {
|
||||
.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> {
|
||||
val parser = newParser(xml)
|
||||
val out = mutableListOf<DavCalendar>()
|
||||
@@ -809,6 +1040,7 @@ object CalDavClient {
|
||||
var calendarColor: String? = null
|
||||
var ctag = ""
|
||||
var isCollection = false
|
||||
var comps = mutableSetOf<String>()
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||
@@ -819,8 +1051,12 @@ object CalDavClient {
|
||||
calendarColor = null
|
||||
ctag = ""
|
||||
isCollection = false
|
||||
comps = mutableSetOf()
|
||||
}
|
||||
"collection" -> if (inResponse) isCollection = true
|
||||
"comp" -> if (inResponse) {
|
||||
parser.getAttributeValue(null, "name")?.trim()?.uppercase()?.let { comps += it }
|
||||
}
|
||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
|
||||
"getctag" -> if (inResponse) ctag = parser.readText().trim()
|
||||
@@ -837,7 +1073,15 @@ object CalDavClient {
|
||||
val name = displayName.ifBlank {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,12 @@ data class CalendarAlarmData(
|
||||
val action: String = "DISPLAY",
|
||||
)
|
||||
|
||||
data class CalendarAttachmentData(
|
||||
val url: String,
|
||||
val fileName: String = "",
|
||||
val mimeType: String = "",
|
||||
)
|
||||
|
||||
data class CalendarEventData(
|
||||
val uid: String,
|
||||
val summary: String,
|
||||
@@ -40,6 +46,7 @@ data class CalendarEventData(
|
||||
val organizerName: String = "",
|
||||
val alarms: List<CalendarAlarmData> = emptyList(),
|
||||
val conferenceUri: String = "",
|
||||
val attachments: List<CalendarAttachmentData> = emptyList(),
|
||||
)
|
||||
|
||||
object CalendarIcs {
|
||||
@@ -99,6 +106,13 @@ object CalendarIcs {
|
||||
val talkUrl = conference.ifBlank {
|
||||
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(
|
||||
uid = uid,
|
||||
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
|
||||
@@ -116,6 +130,7 @@ object CalendarIcs {
|
||||
organizerName = orgName,
|
||||
alarms = alarms,
|
||||
conferenceUri = talkUrl,
|
||||
attachments = attachments,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,6 +178,13 @@ object CalendarIcs {
|
||||
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$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 ->
|
||||
appendLine("BEGIN:VALARM")
|
||||
appendLine("ACTION:${alarm.action}")
|
||||
|
||||
@@ -33,6 +33,14 @@ object NetworkFactory {
|
||||
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(
|
||||
username: 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 libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.material.icons.extended
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.activity.compose
|
||||
|
||||
+112
@@ -25,6 +25,19 @@ data class CalendarLocationSuggestion(
|
||||
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(
|
||||
val timezone: String = "automatic",
|
||||
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 {
|
||||
val client = authedClient(session)
|
||||
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.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -77,7 +77,6 @@ import java.util.Locale
|
||||
private val weekDayLabels = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
|
||||
private val dayTitleFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", 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 fun viewModeIconUrl(base: String, mode: CalendarViewMode): String = when (mode) {
|
||||
@@ -865,6 +864,7 @@ fun CalendarNavigationSidebar(
|
||||
onOpenTasks: () -> Unit,
|
||||
onCreateBook: () -> Unit,
|
||||
onSyncNow: () -> Unit,
|
||||
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
|
||||
) {
|
||||
val base = session.serverUrl.trimEnd('/')
|
||||
val (myCalendars, deckCalendars) = remember(state.calendars) {
|
||||
@@ -916,7 +916,7 @@ fun CalendarNavigationSidebar(
|
||||
onAdd = onCreateBook,
|
||||
)
|
||||
myCalendars.forEach { cal ->
|
||||
CalendarSidebarCalendarRow(cal, onToggleCalendar)
|
||||
CalendarSidebarCalendarRow(cal, onToggleCalendar, onEditCalendar)
|
||||
}
|
||||
if (deckCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -1006,6 +1006,7 @@ private fun CalendarSidebarCaption(
|
||||
private fun CalendarSidebarCalendarRow(
|
||||
cal: CalendarBookItem,
|
||||
onToggleCalendar: (String) -> Unit,
|
||||
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
|
||||
) {
|
||||
val accent = calendarAccentColor(cal)
|
||||
Row(
|
||||
@@ -1029,7 +1030,14 @@ private fun CalendarSidebarCalendarRow(
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 2,
|
||||
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,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
) {
|
||||
val state = rememberDatePickerState(
|
||||
initialSelectedDateMillis = selectedDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(),
|
||||
// Нативный DatePickerDialog вместо material3.DatePicker: material3-версия (BOM 2025.02)
|
||||
// падала при открытии, а платформенный диалог стабилен и сам локализуется под системную
|
||||
// локаль (русские месяцы). Показываем один раз, пока композабл присутствует в дереве.
|
||||
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(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
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)
|
||||
// onDismiss закрывает и по «Отмена», и после выбора (диалог сам себя закрывает).
|
||||
dialog.setOnDismissListener { currentOnDismiss() }
|
||||
dialog.show()
|
||||
onDispose { dialog.setOnDismissListener(null); dialog.dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1111
-67
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -69,6 +69,8 @@ data class CalendarTaskItem(
|
||||
data class CalendarEventDraft(
|
||||
val title: String = "",
|
||||
val date: java.time.LocalDate = java.time.LocalDate.now(),
|
||||
/** Дата окончания; null = совпадает с датой начала (однодневное событие). */
|
||||
val endDate: java.time.LocalDate? = null,
|
||||
val startTime: String = "10:00",
|
||||
val endTime: String = "11:00",
|
||||
val allDay: Boolean = false,
|
||||
@@ -82,9 +84,17 @@ data class CalendarEventDraft(
|
||||
val classification: EventClassification = EventClassification.PUBLIC,
|
||||
val attendees: List<CalendarAttendeeItem> = emptyList(),
|
||||
val attendeeQuery: String = "",
|
||||
val reminderMinutes: Int = 15,
|
||||
/** Напоминания в минутах до начала (0 = во время начала); поддерживается несколько. */
|
||||
val reminders: List<Int> = listOf(15),
|
||||
val addTalkRoom: Boolean = false,
|
||||
val talkRoomUrl: String = "",
|
||||
val attachments: List<CalendarAttachmentItem> = emptyList(),
|
||||
)
|
||||
|
||||
data class CalendarAttachmentItem(
|
||||
val fileName: String,
|
||||
val url: String,
|
||||
val mimeType: String = "",
|
||||
)
|
||||
|
||||
data class CalendarCreateBookDraft(
|
||||
|
||||
+122
-8
@@ -1,18 +1,24 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
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.CalendarAlarmData
|
||||
import ru.forbion.f7cloud.core.network.CalendarAttachmentData
|
||||
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
|
||||
import ru.forbion.f7cloud.core.network.CalendarEventData
|
||||
import ru.forbion.f7cloud.core.network.CalendarIcs
|
||||
import ru.forbion.f7cloud.core.network.CalDavClient
|
||||
import ru.forbion.f7cloud.core.network.DavCalendar
|
||||
import ru.forbion.f7cloud.core.network.DavClient
|
||||
import ru.forbion.f7cloud.core.network.DavEvent
|
||||
import ru.forbion.f7cloud.core.network.DavTask
|
||||
import ru.forbion.f7cloud.core.network.DavTrashEvent
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.davFileUrl
|
||||
import ru.forbion.f7cloud.core.network.davFolderUrl
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
@@ -104,13 +110,111 @@ class CalendarRepository(
|
||||
|
||||
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>) {
|
||||
apiClient.addTalkParticipants(session, callUrl, emails)
|
||||
}
|
||||
|
||||
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
|
||||
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
|
||||
if (draft.addTalkRoom && talkUrl.isBlank()) {
|
||||
talkUrl = apiClient.createTalkRoom(session, draft.title)
|
||||
@@ -197,10 +301,12 @@ class CalendarRepository(
|
||||
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
|
||||
val calendars = CalDavClient.listCalendars(client, base)
|
||||
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()
|
||||
name.contains("личн") || name.contains("personal") || name == "calendar"
|
||||
} ?: calendars.first()
|
||||
} ?: eventCals.first()
|
||||
return CalDavContext(client, calendars, default)
|
||||
}
|
||||
|
||||
@@ -264,6 +370,7 @@ class CalendarRepository(
|
||||
organizerEmail = organizerEmail,
|
||||
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
|
||||
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
|
||||
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
|
||||
source = CalendarEventSource.F7CLOUD,
|
||||
)
|
||||
|
||||
@@ -361,23 +468,27 @@ data class CalendarEventItem(
|
||||
val organizerEmail: String = "",
|
||||
val talkRoomUrl: String = "",
|
||||
val alarms: List<CalendarReminderItem> = emptyList(),
|
||||
val attachments: List<CalendarAttachmentItem> = emptyList(),
|
||||
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
|
||||
)
|
||||
|
||||
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
|
||||
val zone = ZoneId.systemDefault()
|
||||
// Многодневные события: дата окончания отдельная (не раньше даты начала).
|
||||
val endDay = (endDate ?: date).coerceAtLeast(date)
|
||||
val start = if (allDay) {
|
||||
date.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
} else {
|
||||
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val end = if (allDay) {
|
||||
date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val endRaw = if (allDay) {
|
||||
endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
} else {
|
||||
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
parseDateTime(endDay, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
|
||||
?: endDay.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val end = endRaw.coerceAtLeast(start)
|
||||
val rrule = when {
|
||||
recurrence != RecurrencePreset.NONE -> recurrence.rrule
|
||||
customRrule.isNotBlank() -> customRrule
|
||||
@@ -400,8 +511,9 @@ private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String
|
||||
attendees = attendees.map {
|
||||
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,
|
||||
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -421,6 +533,7 @@ private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventDa
|
||||
organizerEmail = organizerEmail,
|
||||
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
|
||||
conferenceUri = talkRoomUrl,
|
||||
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
|
||||
)
|
||||
|
||||
private fun CalendarEventData.toItem(
|
||||
@@ -448,6 +561,7 @@ private fun CalendarEventData.toItem(
|
||||
attendees = attendees.map { it.toItem() },
|
||||
talkRoomUrl = conferenceUri,
|
||||
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)
|
||||
|
||||
@@ -74,6 +74,9 @@ fun CalendarScreen(
|
||||
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 {
|
||||
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
@@ -220,6 +223,48 @@ fun CalendarScreen(
|
||||
onRemoveAttendee = vm::removeAttendee,
|
||||
onSearchLocations = vm::searchLocations,
|
||||
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 = {
|
||||
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 attendeeSuggestions: List<CalendarAttendeeSuggestion> = 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 saving: Boolean = false,
|
||||
val deleting: Boolean = false,
|
||||
@@ -362,7 +379,7 @@ class CalendarViewModel(
|
||||
eventDraft = CalendarEventDraft(
|
||||
date = targetDay,
|
||||
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())
|
||||
}
|
||||
|
||||
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) {
|
||||
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
|
||||
}
|
||||
@@ -428,6 +651,11 @@ class CalendarViewModel(
|
||||
eventDraft = CalendarEventDraft(
|
||||
title = event.summary,
|
||||
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")),
|
||||
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
|
||||
allDay = event.allDay,
|
||||
@@ -440,9 +668,10 @@ class CalendarViewModel(
|
||||
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
|
||||
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
|
||||
attendees = event.attendees,
|
||||
reminderMinutes = event.alarms.firstOrNull()?.minutesBefore ?: 15,
|
||||
reminders = event.alarms.map { it.minutesBefore }.ifEmpty { emptyList() },
|
||||
talkRoomUrl = event.talkRoomUrl,
|
||||
addTalkRoom = event.talkRoomUrl.isNotBlank(),
|
||||
attachments = event.attachments,
|
||||
),
|
||||
focusedEventUid = event.uid,
|
||||
)
|
||||
@@ -541,10 +770,10 @@ class CalendarViewModel(
|
||||
CalendarViewMode.LIST -> "Предстоящие события"
|
||||
CalendarViewMode.WEEK -> {
|
||||
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 ->
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user