From e6a71de7a652b992957411bd0fc643c29a1f8fd6 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Fri, 10 Jul 2026 22:28:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20=D0=BF=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20=E2=80=94=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=201:1=20=D1=81=20=D0=B2=D0=B5=D0=B1,=20=D0=B2=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B5=D0=BD=D0=B8=D0=B5,=20=D1=88=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3,=20free/busy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Русские месяцы в шапке; нативный DatePickerDialog (material3 крашился) - 403 при PUT события: учёт supported-calendar-component-set (VTODO-коллекции не принимают VEVENT) + sabre-precondition в тексте ошибки - Окно события по _calendar-edit-full-fields.css: pill-поля, крестик, зелёная галочка-сохранение, нативные пикеры даты/времени, высота 94% экрана - Talk-беседа к событию: выбор существующей / создание публичной|приватной - Вложения (ATTACH+FILENAME/FMTTYPE): «из файлов» (WebDAV-пикер) или загрузка с устройства в /Calendar; round-trip при редактировании события - Кастомное повторение (_calendar-repeat-modal.css): интервал+частота, дни недели кружками, окончание никогда/до даты/N раз ↔ RRULE (parse/build/описание) - Многодневные события (endDate) и несколько напоминаний (VALARM list) - Управление календарём (✎ в сайдбаре): PROPPATCH имя/цвет, DELETE, шаринг через DAV oc:invite + OCS sharees (польз./группы, чтение/редакт.) - Discard-диалог при закрытии редактора с несохранёнными изменениями - Занятость участников: iTIP VFREEBUSY через DAV outbox, конфликты красным --- .../f7cloud/core/network/CalDavClient.kt | 252 +++- .../f7cloud/core/network/CalendarIcs.kt | 22 + feature/calendar/build.gradle | 1 + .../feature/calendar/CalendarApiClient.kt | 112 ++ .../feature/calendar/CalendarComponents.kt | 55 +- .../feature/calendar/CalendarExtendedUi.kt | 1164 ++++++++++++++++- .../feature/calendar/CalendarModels.kt | 12 +- .../feature/calendar/CalendarRepository.kt | 130 +- .../feature/calendar/CalendarScreen.kt | 50 + .../feature/calendar/CalendarViewModel.kt | 240 +++- .../feature/calendar/RecurrenceRule.kt | 88 ++ 11 files changed, 2026 insertions(+), 100 deletions(-) create mode 100644 feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt index b26d233..62346fe 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt @@ -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 = emptyList(), val conferenceUri: String = "", + val attachments: List = emptyList(), ) data class DavTrashEvent( @@ -112,8 +117,8 @@ object CalDavClient { fun listCalendars(client: OkHttpClient, baseUrl: String): List { val body = """ - - + + """.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: и имя нарушенного precondition-элемента. + val message = Regex("(.*?)", RegexOption.DOT_MATCHES_ALL) + .find(body)?.groupValues?.getOrNull(1)?.trim() + val precondition = Regex("${escapeXml(displayName)}") + if (color != null) append("${escapeXml(color)}") + } + val body = """ + + + $props + + """.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/ или groups/ + val displayName: String, + val writable: Boolean, + ) + + /** Текущие получатели шаринга календаря (oc:invite). */ + fun listCalendarShares(client: OkHttpClient, calendarHref: String): List { + val body = """ + + + + + """.trimIndent() + val xml = propfind(client, calendarHref.trimEnd('/') + "/", depth = 0, body) + val out = mutableListOf() + 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) "" else "" + val body = """ + + + + ${escapeXml(principal)} + $rw + + + """.trimIndent() + sharePost(client, calendarHref, body) + } + + fun unshareCalendar(client: OkHttpClient, calendarHref: String, principal: String) { + val body = """ + + + ${escapeXml(principal)} + + """.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, + rangeStart: Instant, + rangeEnd: Instant, + ): Map> { + 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> { + val out = mutableMapOf>() + 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 { + val unfolded = ics.replace(Regex("\\r?\\n[ \\t]"), "") + val out = mutableListOf() + 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 { val parser = newParser(xml) val out = mutableListOf() @@ -809,6 +1040,7 @@ object CalDavClient { var calendarColor: String? = null var ctag = "" var isCollection = false + var comps = mutableSetOf() 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 diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt index c8e0d0a..7f27046 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt @@ -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 = emptyList(), val conferenceUri: String = "", + val attachments: List = 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}") diff --git a/feature/calendar/build.gradle b/feature/calendar/build.gradle index a1f6104..e08ab7f 100644 --- a/feature/calendar/build.gradle +++ b/feature/calendar/build.gradle @@ -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 diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarApiClient.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarApiClient.kt index a579eda..6f305e4 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarApiClient.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarApiClient.kt @@ -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/ или groups/. */ + 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 { + 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() + 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 { + 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() + 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" diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarComponents.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarComponents.kt index ffd62f8..b5306b5 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarComponents.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarComponents.kt @@ -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( - 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) + // Нативный 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, + ) + // onDismiss закрывает и по «Отмена», и после выбора (диалог сам себя закрывает). + dialog.setOnDismissListener { currentOnDismiss() } + dialog.show() + onDispose { dialog.setOnDismissListener(null); dialog.dismiss() } } } diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarExtendedUi.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarExtendedUi.kt index 4c36a3a..fd060bc 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarExtendedUi.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarExtendedUi.kt @@ -1,40 +1,94 @@ package ru.forbion.f7cloud.feature.calendar 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.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.LocalOffer +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton +import java.time.LocalDate import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -210,81 +264,1071 @@ fun CalendarFullEventEditorDialog( onRemoveAttendee: (String) -> Unit, onSearchLocations: (String) -> Unit, onApplyLocation: (CalendarLocationSuggestion) -> Unit, + onOpenTalkPicker: () -> Unit = {}, + onPickDeviceAttachment: () -> Unit = {}, + onPickFilesAttachment: () -> Unit = {}, + onRemoveAttachment: (String) -> Unit = {}, + onOpenFreeBusy: () -> Unit = {}, ) { val draft = state.eventDraft val isEdit = state.editorMode == CalendarEditorMode.EDIT - AlertDialog( - onDismissRequest = { if (!state.saving && !state.deleting) onDismiss() }, - title = { Text(if (isEdit) "Редактировать событие" else "Создать событие") }, - text = { - Column(Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { - F7OutlinedField(value = draft.title, onValueChange = { onDraftChange(draft.copy(title = it)) }, label = "Название") - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("Весь день"); Switch(checked = draft.allDay, onCheckedChange = { onDraftChange(draft.copy(allDay = it)) }) + val context = LocalContext.current + val dateFmt = remember { DateTimeFormatter.ofPattern("d MMM yyyy", Locale.forLanguageTag("ru")) } + val selectedCalendar = state.calendars.firstOrNull { it.href == draft.calendarHref } + ?: state.calendars.firstOrNull { it.visible } + + fun pickDate(current: LocalDate, onPick: (LocalDate) -> Unit) { + android.app.DatePickerDialog( + context, + { _, y, m, d -> onPick(LocalDate.of(y, m + 1, d)) }, + current.year, current.monthValue - 1, current.dayOfMonth, + ).show() + } + fun pickTime(current: String, onPick: (String) -> Unit) { + val parts = current.split(":") + val h = parts.getOrNull(0)?.toIntOrNull() ?: 10 + val mi = parts.getOrNull(1)?.toIntOrNull() ?: 0 + android.app.TimePickerDialog( + context, + { _, hh, mm -> onPick("%02d:%02d".format(hh, mm)) }, + h, mi, true, + ).show() + } + + // Discard-диалог: закрытие с несохранёнными изменениями требует подтверждения + // (_calendar-discard-dialog-mobile.css). Исходник фиксируем на открытии окна. + val initialDraft = remember { draft } + var discardConfirm by remember { mutableStateOf(false) } + val requestClose: () -> Unit = { + if (!state.saving && !state.deleting) { + if (draft != initialDraft) discardConfirm = true else onDismiss() + } + } + if (discardConfirm) { + AlertDialog( + onDismissRequest = { discardConfirm = false }, + title = { Text("Отменить изменения?") }, + text = { Text("Внесённые изменения не будут сохранены.") }, + confirmButton = { + TextButton(onClick = { discardConfirm = false; onDismiss() }) { + Text("Отменить изменения", color = F7Colors.Error) } - if (!draft.allDay) { - F7OutlinedField(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало") - F7OutlinedField(value = draft.endTime, onValueChange = { onDraftChange(draft.copy(endTime = it)) }, label = "Окончание") + }, + dismissButton = { + TextButton(onClick = { discardConfirm = false }) { + Text("Продолжить редактирование", color = F7Colors.TextPrimary) } - Text("Повторение", style = MaterialTheme.typography.labelMedium) - RecurrencePreset.entries.forEach { preset -> - Row(Modifier.clickable { onDraftChange(draft.copy(recurrence = preset, customRrule = "")) }, verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = draft.recurrence == preset, onClick = { onDraftChange(draft.copy(recurrence = preset)) }) - Text(preset.label, modifier = Modifier.padding(start = 4.dp)) + }, + ) + } + + Dialog( + onDismissRequest = requestClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth(0.94f).fillMaxHeight(0.94f), + ) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + // Шапка: заголовок + крестик + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (isEdit) "Редактировать событие" else "Создать событие", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = F7Colors.TextPrimary, + ) + IconButton(onClick = requestClose, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = F7Colors.TextSecondary) } } - F7OutlinedField(value = draft.location, onValueChange = { onSearchLocations(it) }, label = "Место") - state.locationSuggestions.take(4).forEach { s -> - Text(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary) - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("Комната Talk"); Switch(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) }) - } - F7OutlinedField(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание") - F7OutlinedField(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории") - F7OutlinedField(value = draft.reminderMinutes.toString(), onValueChange = { v -> v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)") - Text("Статус", style = MaterialTheme.typography.labelMedium) - EventStatus.entries.forEach { st -> - Row(Modifier.clickable { onDraftChange(draft.copy(status = st)) }, verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = draft.status == st, onClick = { onDraftChange(draft.copy(status = st)) }) - Text(st.label, modifier = Modifier.padding(start = 4.dp)) + Spacer(Modifier.height(12.dp)) + + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Название + зелёная галочка-сохранение + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + EventPill(icon = null, modifier = Modifier.weight(1f)) { + EventInput( + value = draft.title, + onValueChange = { onDraftChange(draft.copy(title = it)) }, + placeholder = "Название", + modifier = Modifier.weight(1f), + ) + } + EventSaveButton(enabled = !state.saving, onClick = onSave) } - } - Text("Видимость", style = MaterialTheme.typography.labelMedium) - EventClassification.entries.forEach { cl -> - Row(Modifier.clickable { onDraftChange(draft.copy(classification = cl)) }, verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = draft.classification == cl, onClick = { onDraftChange(draft.copy(classification = cl)) }) - Text(cl.label, modifier = Modifier.padding(start = 4.dp)) + + // Начало + EventFieldLabel("Начало") + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + EventPill(icon = Icons.Default.DateRange, modifier = Modifier.weight(1f), onClick = { + pickDate(draft.date) { picked -> + // endDate не может быть раньше начала; равенство = однодневное (null) + val newEnd = draft.endDate?.coerceAtLeast(picked)?.takeIf { it != picked } + onDraftChange(draft.copy(date = picked, endDate = newEnd)) + } + }) { + Text(draft.date.format(dateFmt), color = EVENT_TEXT, fontSize = 14.sp) + } + if (!draft.allDay) { + EventPill(icon = Icons.Default.Schedule, modifier = Modifier.weight(1f), onClick = { pickTime(draft.startTime) { onDraftChange(draft.copy(startTime = it)) } }) { + Text(draft.startTime, color = EVENT_TEXT, fontSize = 14.sp) + } + } } - } - Text("Участники", style = MaterialTheme.typography.labelMedium) - F7OutlinedField(value = draft.attendeeQuery, onValueChange = onSearchAttendees, label = "Поиск участника") - state.attendeeSuggestions.take(5).forEach { s -> - Text("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary) - } - draft.attendees.forEach { a -> - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("${a.displayName.ifBlank { a.email }}") - F7TextButton(text = "✕", onClick = { onRemoveAttendee(a.email) }) + + // Окончание (дата может отличаться от начала — многодневные события) + EventFieldLabel("Окончание") + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + val endDay = (draft.endDate ?: draft.date).coerceAtLeast(draft.date) + EventPill(icon = Icons.Default.DateRange, modifier = Modifier.weight(1f), onClick = { + pickDate(endDay) { picked -> + onDraftChange(draft.copy(endDate = picked.coerceAtLeast(draft.date).takeIf { it != draft.date })) + } + }) { + Text(endDay.format(dateFmt), color = EVENT_TEXT, fontSize = 14.sp) + } + if (!draft.allDay) { + EventPill(icon = Icons.Default.Schedule, modifier = Modifier.weight(1f), onClick = { pickTime(draft.endTime) { onDraftChange(draft.copy(endTime = it)) } }) { + Text(draft.endTime, color = EVENT_TEXT, fontSize = 14.sp) + } + } } - } - if (state.calendars.isNotEmpty()) { - Text("Календарь", style = MaterialTheme.typography.labelMedium) - state.calendars.filter { it.visible }.forEach { cal -> - Row(Modifier.clickable { onDraftChange(draft.copy(calendarHref = cal.href)) }, verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) }) - Text(cal.displayName, modifier = Modifier.padding(start = 4.dp)) + + // Таймзона (инфо) + EventPill(icon = Icons.Default.Public) { + Text(java.time.ZoneId.systemDefault().id, color = EVENT_TEXT, fontSize = 14.sp) + } + + // Повторение: пресеты + «Настроить…» (кастомный RRULE в модалке как на сайте) + var repeatModalOpen by remember { mutableStateOf(false) } + EventSelectPill( + icon = Icons.Default.Autorenew, + label = when { + draft.customRrule.isNotBlank() -> RecurrenceRule.describe(draft.customRrule) + draft.recurrence == RecurrencePreset.NONE -> "Не повторять" + else -> draft.recurrence.label + }, + trailing = Icons.Default.Edit, + options = RecurrencePreset.entries.map { p -> + p.label to { onDraftChange(draft.copy(recurrence = p, customRrule = "")) } + } + ("Настроить…" to { repeatModalOpen = true }), + ) + if (repeatModalOpen) { + CalendarRepeatModal( + initial = RecurrenceRule.parse(draft.customRrule.ifBlank { draft.recurrence.rrule }) + ?: RecurrenceRule(byDays = setOf(RecurrenceRule.ICS_DAYS[draft.date.dayOfWeek.value - 1])), + onDismiss = { repeatModalOpen = false }, + onApply = { rule -> + onDraftChange(draft.copy(recurrence = RecurrencePreset.NONE, customRrule = rule.toRrule())) + repeatModalOpen = false + }, + onClear = { + onDraftChange(draft.copy(recurrence = RecurrencePreset.NONE, customRrule = "")) + repeatModalOpen = false + }, + ) + } + + // Весь день + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onDraftChange(draft.copy(allDay = !draft.allDay)) }) { + Checkbox( + checked = draft.allDay, + onCheckedChange = { onDraftChange(draft.copy(allDay = it)) }, + colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary), + ) + Text("Весь день", color = F7Colors.TextPrimary, fontSize = 14.sp) + } + + // Календарь + if (state.calendars.isNotEmpty()) { + EventCalendarPill( + calendarName = selectedCalendar?.displayName ?: "Календарь", + colorHex = selectedCalendar?.color, + options = state.calendars.filter { it.visible }, + onSelect = { onDraftChange(draft.copy(calendarHref = it.href)) }, + ) + } + + // Описание + EventPill(icon = Icons.Default.Notes, minHeight = 120.dp, iconTop = true) { + EventInput( + value = draft.description, + onValueChange = { onDraftChange(draft.copy(description = it)) }, + placeholder = "Добавьте описание", + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + } + + // Местоположение + добавить Talk-комнату (зелёный +) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + EventPill(icon = Icons.Default.Place, modifier = Modifier.weight(1f)) { + EventInput( + value = draft.location, + onValueChange = { onSearchLocations(it) }, + placeholder = "Добавить местоположение", + modifier = Modifier.weight(1f), + ) + } + EventRoundButton( + icon = Icons.Default.Add, + active = draft.talkRoomUrl.isNotBlank(), + onClick = onOpenTalkPicker, + ) + } + if (draft.talkRoomUrl.isNotBlank()) { + Text( + "Беседа Talk прикреплена", + modifier = Modifier.padding(start = 8.dp), + color = F7Colors.Primary, + fontSize = 13.sp, + ) + } + state.locationSuggestions.take(4).forEach { s -> + Text( + s.address.ifBlank { s.name }, + modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), + color = F7Colors.Primary, + fontSize = 13.sp, + ) + } + + // Напоминания: несколько, добавление из пресетов, удаление крестиком + EventSelectPill( + icon = Icons.Default.Notifications, + label = if (draft.reminders.isEmpty()) "Добавить напоминание" else "Напоминаний: ${draft.reminders.size}", + trailing = Icons.Default.KeyboardArrowDown, + options = REMINDER_PRESETS + .filter { (min, _) -> min !in draft.reminders } + .map { (min, lbl) -> lbl to { onDraftChange(draft.copy(reminders = (draft.reminders + min).sorted())) } }, + ) + draft.reminders.forEach { min -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp)) { + Icon(Icons.Default.Notifications, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text(reminderLabelFor(min), color = F7Colors.TextPrimary, fontSize = 13.sp, modifier = Modifier.weight(1f)) + IconButton(onClick = { onDraftChange(draft.copy(reminders = draft.reminders - min)) }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.Close, contentDescription = "Удалить напоминание", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp)) + } + } + } + + // Вложения: меню «Из файлов / Загрузить с устройства» + список с удалением + var attachMenu by remember { mutableStateOf(false) } + Box { + EventPill(icon = Icons.Default.AttachFile, onClick = { attachMenu = true }) { + Text( + if (draft.attachments.isEmpty()) "Нет ни одного вложения" else "Вложений: ${draft.attachments.size}", + color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f), + ) + if (state.attachmentUploading) { + Text("Загрузка…", color = EVENT_PLACEHOLDER, fontSize = 12.sp) + } else { + Icon(Icons.Default.Add, contentDescription = "Добавить вложение", tint = EVENT_PLACEHOLDER, modifier = Modifier.size(18.dp)) + } + } + DropdownMenu(expanded = attachMenu, onDismissRequest = { attachMenu = false }) { + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null) }, + text = { Text("Добавить из файлов") }, + onClick = { attachMenu = false; onPickFilesAttachment() }, + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Upload, contentDescription = null) }, + text = { Text("Загрузить с устройства") }, + onClick = { attachMenu = false; onPickDeviceAttachment() }, + ) + } + } + draft.attachments.forEach { att -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp)) { + Icon(Icons.Default.InsertDriveFile, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text(att.fileName.ifBlank { att.url.substringAfterLast('/') }, color = F7Colors.TextPrimary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + IconButton(onClick = { onRemoveAttachment(att.url) }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.Close, contentDescription = "Удалить", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp)) + } + } + } + + // Статус + EventSelectPill( + icon = Icons.Default.Check, + label = draft.status.label, + trailing = Icons.Default.KeyboardArrowDown, + options = EventStatus.entries.map { st -> st.label to { onDraftChange(draft.copy(status = st)) } }, + ) + + // Видимость + EventSelectPill( + icon = Icons.Default.Visibility, + label = draft.classification.label, + trailing = Icons.Default.KeyboardArrowDown, + options = EventClassification.entries.map { cl -> cl.label to { onDraftChange(draft.copy(classification = cl)) } }, + ) + + // Категории + EventPill(icon = Icons.Default.LocalOffer) { + EventInput( + value = draft.categories, + onValueChange = { onDraftChange(draft.copy(categories = it)) }, + placeholder = "Категории", + modifier = Modifier.weight(1f), + ) + } + + // Участники + EventPill(icon = Icons.Default.Add) { + EventInput( + value = draft.attendeeQuery, + onValueChange = onSearchAttendees, + placeholder = "Добавить участника", + modifier = Modifier.weight(1f), + ) + } + state.attendeeSuggestions.take(5).forEach { s -> + Text( + "${s.name} · ${s.email}", + modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), + color = F7Colors.Primary, + fontSize = 13.sp, + ) + } + draft.attendees.forEach { a -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(a.displayName.ifBlank { a.email }, color = F7Colors.TextPrimary, fontSize = 14.sp) + F7TextButton(text = "✕", onClick = { onRemoveAttendee(a.email) }) + } + } + if (draft.attendees.isNotEmpty()) { + TextButton(onClick = onOpenFreeBusy) { + Text("Показать занятость участников", color = F7Colors.Primary, fontSize = 13.sp) } } } + + // Футер + Spacer(Modifier.height(12.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isEdit) { + TextButton(onClick = onDelete, enabled = !state.deleting) { + Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error) + } + Spacer(Modifier.width(4.dp)) + } + TextButton(onClick = requestClose, enabled = !state.saving) { + Text("Отмена", color = F7Colors.TextSecondary) + } + Spacer(Modifier.width(8.dp)) + F7PrimaryButton( + text = if (state.saving) "Сохранение…" else "Сохранить", + onClick = onSave, + enabled = !state.saving, + ) + } } - }, - confirmButton = { F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving) }, - dismissButton = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (isEdit) TextButton(onClick = onDelete, enabled = !state.deleting) { Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error) } - TextButton(onClick = onDismiss) { Text("Отмена") } + } + } +} + +// --- Токены и вспомогательные композаблы редактора события (см. _calendar-edit-full-fields.css) --- +private val EVENT_FIELD_BG = Color(0xFFFBFBFB) +private val EVENT_FIELD_BORDER = Color(0xFFE6E6E6) +private val EVENT_TEXT = Color(0xFF151515) +private val EVENT_PLACEHOLDER = Color(0xFF808080) + +private val REMINDER_PRESETS = listOf( + 0 to "во время начала события", + 5 to "за 5 минут", + 10 to "за 10 минут", + 15 to "за 15 минут", + 30 to "за 30 минут", + 60 to "за 1 час", + 1440 to "за 1 день", +) + +private fun reminderLabelFor(min: Int): String = + REMINDER_PRESETS.firstOrNull { it.first == min }?.second ?: "за $min мин" + +@Composable +private fun EventFieldLabel(text: String) { + Text(text, color = F7Colors.TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) +} + +@Composable +private fun EventPill( + icon: ImageVector?, + modifier: Modifier = Modifier, + minHeight: androidx.compose.ui.unit.Dp = 40.dp, + iconTop: Boolean = false, + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .clip(RoundedCornerShape(8.dp)) + .background(EVENT_FIELD_BG) + .border(1.dp, EVENT_FIELD_BORDER, RoundedCornerShape(8.dp)) + .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier) + .padding(horizontal = 12.dp, vertical = if (minHeight > 40.dp) 12.dp else 0.dp), + verticalAlignment = if (iconTop) Alignment.Top else Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + icon, + contentDescription = null, + tint = EVENT_PLACEHOLDER, + modifier = Modifier.size(16.dp).then(if (iconTop) Modifier.padding(top = 2.dp) else Modifier), + ) + Spacer(Modifier.width(12.dp)) + } + content() + } +} + +@Composable +private fun EventInput( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + singleLine: Boolean = true, +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + textStyle = TextStyle(color = EVENT_TEXT, fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium), + cursorBrush = SolidColor(F7Colors.Primary), + modifier = modifier, + decorationBox = { inner -> + Box { + if (value.isEmpty()) { + Text(placeholder, color = EVENT_PLACEHOLDER, fontSize = 14.sp, lineHeight = 20.sp) + } + inner() } }, ) } + +@Composable +private fun EventSelectPill( + icon: ImageVector, + label: String, + trailing: ImageVector, + options: List Unit>>, +) { + var expanded by remember { mutableStateOf(false) } + Box { + EventPill(icon = icon, onClick = { expanded = true }) { + Text(label, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Icon(trailing, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (text, action) -> + DropdownMenuItem(text = { Text(text) }, onClick = { action(); expanded = false }) + } + } + } +} + +@Composable +private fun EventCalendarPill( + calendarName: String, + colorHex: String?, + options: List, + onSelect: (CalendarBookItem) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val dotColor = parseCalendarColor(colorHex) + Box { + EventPill(icon = null, onClick = { expanded = true }) { + Box(Modifier.size(12.dp).clip(CircleShape).background(dotColor)) + Spacer(Modifier.width(12.dp)) + Text(calendarName, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Box(Modifier.size(24.dp).clip(CircleShape).background(F7Colors.Primary), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Edit, contentDescription = null, tint = Color.White, modifier = Modifier.size(13.dp)) + } + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { cal -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(10.dp).clip(CircleShape).background(parseCalendarColor(cal.color))) + Spacer(Modifier.width(8.dp)) + Text(cal.displayName) + } + }, + onClick = { onSelect(cal); expanded = false }, + ) + } + } + } +} + +@Composable +private fun EventSaveButton(enabled: Boolean, onClick: () -> Unit) { + Box( + Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (enabled) F7Colors.Primary else F7Colors.Border) + .clickable(enabled = enabled) { onClick() }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Check, contentDescription = "Сохранить", tint = Color.White, modifier = Modifier.size(20.dp)) + } +} + +@Composable +private fun EventRoundButton(icon: ImageVector, active: Boolean, onClick: () -> Unit) { + Box( + Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (active) F7Colors.Primary else F7Colors.PrimaryGradientStart) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) + } +} + +private fun parseCalendarColor(hex: String?): Color { + val raw = hex?.trim()?.removePrefix("#") ?: return Color(0xFF0082C9) + return runCatching { + val v = when (raw.length) { + 6 -> raw.toLong(16) or 0xFF000000 + 8 -> raw.toLong(16) + 3 -> { + val r = raw[0]; val g = raw[1]; val b = raw[2] + "$r$r$g$g$b$b".toLong(16) or 0xFF000000 + } + else -> return Color(0xFF0082C9) + } + Color(v) + }.getOrDefault(Color(0xFF0082C9)) +} + +/** + * Выбор беседы Talk для события (кнопка «+» у поля местоположения). Как на вебе: + * список существующих бесед + создание публичной/приватной. + */ +@Composable +fun CalendarTalkPickerDialog( + state: CalendarUiState, + onDismiss: () -> Unit, + onSelect: (CalendarTalkRoom) -> Unit, + onCreate: (Int) -> Unit, +) { + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth(0.94f).fillMaxHeight(0.9f), + ) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Выберите беседу", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary) + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = F7Colors.TextSecondary) + } + } + Spacer(Modifier.height(8.dp)) + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(2.dp)) { + when { + state.talkPickerLoading -> Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp)) + state.talkRooms.isEmpty() -> Text("Нет доступных бесед", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp)) + else -> state.talkRooms.forEach { room -> + Row( + Modifier.fillMaxWidth().clickable { onSelect(room) }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(32.dp).clip(CircleShape).background(F7Colors.PrimaryLight), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Link, contentDescription = null, tint = F7Colors.Primary, modifier = Modifier.size(18.dp)) + } + Spacer(Modifier.width(12.dp)) + Text(room.displayName, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + HorizontalDivider(color = F7Colors.BorderLight) + } + } + } + Spacer(Modifier.height(12.dp)) + Text("Или создать новую беседу", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + TalkCreateRow("Создать публичную беседу", enabled = !state.talkPickerLoading) { onCreate(3) } + Spacer(Modifier.height(8.dp)) + TalkCreateRow("Создать приватную беседу", enabled = !state.talkPickerLoading) { onCreate(2) } + } + } + } +} + +@Composable +private fun TalkCreateRow(label: String, enabled: Boolean, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .clip(RoundedCornerShape(8.dp)) + .background(F7Colors.PrimaryLight) + .clickable(enabled = enabled) { onClick() } + .padding(horizontal = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = F7Colors.TextPrimary, fontSize = 14.sp) + Icon(Icons.Default.Add, contentDescription = null, tint = F7Colors.Primary, modifier = Modifier.size(18.dp)) + } +} + +/** Занятость участников на день события (free/busy): интервалы занятости, конфликт — красным. */ +@Composable +fun CalendarFreeBusyDialog( + state: CalendarUiState, + onDismiss: () -> Unit, +) { + val draft = state.eventDraft + val zone = java.time.ZoneId.systemDefault() + val timeFmt = remember { DateTimeFormatter.ofPattern("HH:mm") } + // Окно события для подсветки конфликтов + val eventStart = remember(draft) { + if (draft.allDay) draft.date.atStartOfDay(zone).toInstant().toEpochMilli() + else draft.date.atTime( + draft.startTime.substringBefore(':').toIntOrNull() ?: 10, + draft.startTime.substringAfter(':').toIntOrNull() ?: 0, + ).atZone(zone).toInstant().toEpochMilli() + } + val eventEnd = remember(draft) { + val endDay = (draft.endDate ?: draft.date).coerceAtLeast(draft.date) + if (draft.allDay) endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + else endDay.atTime( + draft.endTime.substringBefore(':').toIntOrNull() ?: 11, + draft.endTime.substringAfter(':').toIntOrNull() ?: 0, + ).atZone(zone).toInstant().toEpochMilli() + } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth(0.94f), + ) { + Column( + Modifier.padding(horizontal = 16.dp, vertical = 14.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Занятость участников", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary) + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = F7Colors.TextSecondary) + } + } + Text( + draft.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))), + color = F7Colors.TextSecondary, fontSize = 13.sp, + ) + when { + state.freeBusyLoading -> Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp) + state.freeBusyError != null -> Text(state.freeBusyError, color = F7Colors.Error, fontSize = 14.sp) + else -> draft.attendees.forEach { attendee -> + val busy = state.freeBusyResult[attendee.email].orEmpty() + val conflict = busy.any { it.startEpochMilli < eventEnd && it.endEpochMilli > eventStart } + Column(Modifier.fillMaxWidth()) { + Text( + attendee.displayName.ifBlank { attendee.email }, + color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, + ) + if (busy.isEmpty()) { + Text("Свободен весь день", color = F7Colors.Primary, fontSize = 13.sp) + } else { + busy.forEach { p -> + val overlaps = p.startEpochMilli < eventEnd && p.endEpochMilli > eventStart + val s = java.time.Instant.ofEpochMilli(p.startEpochMilli).atZone(zone) + val e = java.time.Instant.ofEpochMilli(p.endEpochMilli).atZone(zone) + Text( + "Занят ${s.format(timeFmt)}–${e.format(timeFmt)}", + color = if (overlaps) F7Colors.Error else F7Colors.TextSecondary, + fontSize = 13.sp, + ) + } + } + if (conflict) { + Text("⚠ Конфликт со временем события", color = F7Colors.Error, fontSize = 12.sp) + } + } + HorizontalDivider(color = F7Colors.BorderLight) + } + } + } + } + } +} + +/** Окно управления календарём: имя, цвет, шаринг, удаление (по _calendar-edit-calendar-modal-mobile.css). */ +@Composable +fun CalendarEditBookDialog( + state: CalendarUiState, + onDismiss: () -> Unit, + onSave: (String, String?) -> Unit, + onDelete: () -> Unit, + onSearchSharees: (String) -> Unit, + onAddSharee: (CalendarShareeSuggestion, Boolean) -> Unit, + onRemoveSharee: (String) -> Unit, +) { + val target = state.editCalendarTarget ?: return + var name by remember(target.href) { mutableStateOf(target.displayName) } + var color by remember(target.href) { mutableStateOf(target.color) } + var shareQuery by remember(target.href) { mutableStateOf("") } + var confirmDelete by remember(target.href) { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth(0.94f), + ) { + Column( + Modifier.padding(horizontal = 16.dp, vertical = 14.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Настройки календаря", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary) + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = F7Colors.TextSecondary) + } + } + + EventFieldLabel("Название") + EventPill(icon = null) { + EventInput(value = name, onValueChange = { name = it }, placeholder = "Название календаря", modifier = Modifier.weight(1f)) + } + + EventFieldLabel("Цвет") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CALENDAR_PALETTE.forEach { hex -> + val selected = color?.equals(hex, ignoreCase = true) == true + Box( + Modifier + .weight(1f) + .height(36.dp) + .clip(CircleShape) + .background(parseCalendarColor(hex)) + .then(if (selected) Modifier.border(3.dp, F7Colors.TextPrimary, CircleShape) else Modifier) + .clickable { color = hex }, + ) + } + } + + EventFieldLabel("Поделиться") + EventPill(icon = Icons.Default.Add) { + EventInput( + value = shareQuery, + onValueChange = { shareQuery = it; onSearchSharees(it) }, + placeholder = "Пользователь или группа", + modifier = Modifier.weight(1f), + ) + } + state.shareeSuggestions.take(5).forEach { s -> + Text( + (if (s.isGroup) "Группа: " else "") + s.displayName, + modifier = Modifier.clickable { onAddSharee(s, true); shareQuery = "" }.padding(start = 8.dp), + color = F7Colors.Primary, + fontSize = 13.sp, + ) + } + state.editCalendarShares.forEach { sharee -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + sharee.displayName + if (sharee.writable) " (редактирование)" else " (чтение)", + color = F7Colors.TextPrimary, fontSize = 14.sp, modifier = Modifier.weight(1f), + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + IconButton(onClick = { onRemoveSharee(sharee.principal) }, modifier = Modifier.size(24.dp)) { + Icon(Icons.Default.Close, contentDescription = "Убрать доступ", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp)) + } + } + } + + HorizontalDivider(color = F7Colors.BorderLight) + if (!confirmDelete) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { confirmDelete = true }, enabled = !state.editCalendarBusy) { + Text("Удалить календарь", color = F7Colors.Error) + } + F7PrimaryButton( + text = if (state.editCalendarBusy) "Сохранение…" else "Сохранить", + onClick = { onSave(name, color) }, + enabled = !state.editCalendarBusy, + ) + } + } else { + Text("Удалить календарь и все его события?", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { confirmDelete = false }) { Text("Отмена", color = F7Colors.TextSecondary) } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onDelete, enabled = !state.editCalendarBusy) { + Text(if (state.editCalendarBusy) "Удаление…" else "Удалить", color = F7Colors.Error) + } + } + } + } + } + } +} + +/** Стандартная палитра Nextcloud для календарей. */ +private val CALENDAR_PALETTE = listOf( + "#0082C9", "#7CBC3D", "#E9322D", "#F1DB50", "#795AAB", "#E97D30", "#00C7B4", +) + +/** Модалка «Повторять событие» — 1:1 с _calendar-repeat-modal.css (интервал+частота, + * кружки дней недели для недельного, окончание: никогда/до даты/N раз). */ +@Composable +fun CalendarRepeatModal( + initial: RecurrenceRule, + onDismiss: () -> Unit, + onApply: (RecurrenceRule) -> Unit, + onClear: () -> Unit, +) { + var rule by remember { mutableStateOf(initial) } + val context = LocalContext.current + val dateFmt = remember { DateTimeFormatter.ofPattern("dd.MM.yyyy") } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth(0.94f), + ) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Повторять событие", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary) + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = F7Colors.TextSecondary) + } + } + + // Интервал + частота + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + EventPill(icon = null, modifier = Modifier.width(96.dp)) { + EventInput( + value = rule.interval.toString(), + onValueChange = { v -> + val n = v.filter { it.isDigit() }.take(3).toIntOrNull() + rule = rule.copy(interval = (n ?: 1).coerceAtLeast(1)) + }, + placeholder = "1", + modifier = Modifier.weight(1f), + ) + } + var freqMenu by remember { mutableStateOf(false) } + Box(Modifier.weight(1f)) { + EventPill(icon = null, onClick = { freqMenu = true }) { + Text(freqLabel(rule.freq, rule.interval), color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f)) + Icon(Icons.Default.KeyboardArrowDown, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp)) + } + DropdownMenu(expanded = freqMenu, onDismissRequest = { freqMenu = false }) { + listOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY").forEach { f -> + DropdownMenuItem( + text = { Text(freqLabel(f, rule.interval)) }, + onClick = { rule = rule.copy(freq = f); freqMenu = false }, + ) + } + } + } + } + + // Дни недели (только WEEKLY): кружки 40dp, выбранный — зелёный градиент + if (rule.freq == "WEEKLY") { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RecurrenceRule.ICS_DAYS.forEach { day -> + val selected = day in rule.byDays + Box( + Modifier + .weight(1f) + .height(40.dp) + .clip(CircleShape) + .then( + if (selected) { + Modifier.background( + androidx.compose.ui.graphics.Brush.linearGradient( + listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd), + ), + ) + } else { + Modifier + .background(EVENT_FIELD_BG) + .border(1.dp, EVENT_FIELD_BORDER, CircleShape) + }, + ) + .clickable { + rule = rule.copy( + byDays = if (selected) rule.byDays - day else rule.byDays + day, + ) + }, + contentAlignment = Alignment.Center, + ) { + Text( + RecurrenceRule.DAY_LABELS[day] ?: day, + color = EVENT_TEXT, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + + // Окончание + EventFieldLabel("Окончание") + var endMenu by remember { mutableStateOf(false) } + Box { + EventPill(icon = null, onClick = { endMenu = true }) { + Text( + when (rule.endType) { + RecurrenceRule.EndType.NEVER -> "Никогда" + RecurrenceRule.EndType.UNTIL -> "До даты" + RecurrenceRule.EndType.COUNT -> "После N повторений" + }, + color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f), + ) + Icon(Icons.Default.KeyboardArrowDown, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp)) + } + DropdownMenu(expanded = endMenu, onDismissRequest = { endMenu = false }) { + DropdownMenuItem(text = { Text("Никогда") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.NEVER); endMenu = false }) + DropdownMenuItem(text = { Text("До даты") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.UNTIL, untilDate = rule.untilDate ?: LocalDate.now().plusMonths(1)); endMenu = false }) + DropdownMenuItem(text = { Text("После N повторений") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.COUNT); endMenu = false }) + } + } + when (rule.endType) { + RecurrenceRule.EndType.UNTIL -> { + val until = rule.untilDate ?: LocalDate.now().plusMonths(1) + EventPill(icon = Icons.Default.DateRange, onClick = { + android.app.DatePickerDialog( + context, + { _, y, m, d -> rule = rule.copy(untilDate = LocalDate.of(y, m + 1, d)) }, + until.year, until.monthValue - 1, until.dayOfMonth, + ).show() + }) { + Text(until.format(dateFmt), color = EVENT_TEXT, fontSize = 14.sp) + } + } + RecurrenceRule.EndType.COUNT -> { + EventPill(icon = null) { + EventInput( + value = rule.count.toString(), + onValueChange = { v -> + val n = v.filter { it.isDigit() }.take(3).toIntOrNull() + rule = rule.copy(count = (n ?: 1).coerceAtLeast(1)) + }, + placeholder = "10", + modifier = Modifier.weight(1f), + ) + Text("раз", color = EVENT_PLACEHOLDER, fontSize = 14.sp) + } + } + RecurrenceRule.EndType.NEVER -> Unit + } + + // Футер + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onClear) { Text("Не повторять", color = F7Colors.TextSecondary) } + Spacer(Modifier.width(8.dp)) + F7PrimaryButton(text = "Применить", onClick = { onApply(rule) }) + } + } + } + } +} + +private fun freqLabel(freq: String, interval: Int): String = when (freq) { + "DAILY" -> if (interval == 1) "день" else "дней" + "WEEKLY" -> if (interval == 1) "неделя" else "недель" + "MONTHLY" -> if (interval == 1) "месяц" else "месяцев" + else -> if (interval == 1) "год" else "лет" +} + +@Composable +fun CalendarFilesPickerDialog( + state: CalendarUiState, + onDismiss: () -> Unit, + onNavigate: (String) -> Unit, + onPick: (ru.forbion.f7cloud.core.network.DavClient.DavEntry) -> Unit, +) { + val path = state.filesPickerPath + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth(0.94f).fillMaxHeight(0.9f), + ) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Выберите файл", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary) + IconButton(onClick = onDismiss, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = "Закрыть", tint = F7Colors.TextSecondary) + } + } + // Хлебная крошка / кнопка «вверх» + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + if (path.isNotBlank()) { + IconButton(onClick = { onNavigate(path.substringBeforeLast('/', "")) }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.ArrowBack, contentDescription = "Назад", tint = F7Colors.TextSecondary) + } + Spacer(Modifier.width(6.dp)) + } + Text("/${path}", color = F7Colors.TextSecondary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + HorizontalDivider(color = F7Colors.BorderLight) + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + when { + state.filesPickerLoading -> Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp)) + state.filesPickerEntries.isEmpty() -> Text("Папка пуста", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp)) + else -> state.filesPickerEntries.forEach { entry -> + Row( + Modifier.fillMaxWidth().clickable { + if (entry.isDirectory) { + onNavigate(if (path.isBlank()) entry.name else "$path/${entry.name}") + } else { + onPick(entry) + } + }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (entry.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile, + contentDescription = null, + tint = if (entry.isDirectory) F7Colors.Primary else F7Colors.TextSecondary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + Text(entry.name, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + HorizontalDivider(color = F7Colors.BorderLight) + } + } + } + } + } + } +} diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarModels.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarModels.kt index 248fd4a..3284e2a 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarModels.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarModels.kt @@ -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 = emptyList(), val attendeeQuery: String = "", - val reminderMinutes: Int = 15, + /** Напоминания в минутах до начала (0 = во время начала); поддерживается несколько. */ + val reminders: List = listOf(15), val addTalkRoom: Boolean = false, val talkRoomUrl: String = "", + val attachments: List = emptyList(), +) + +data class CalendarAttachmentItem( + val fileName: String, + val url: String, + val mimeType: String = "", ) data class CalendarCreateBookDraft( diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt index 4031521..b6d2d0e 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt @@ -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 = 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.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 = + apiClient.searchSharees(session, query) + + /** Занятость участников (email → интервалы) за диапазон. */ + fun freeBusy( + session: AuthSession, + attendeeEmails: List, + rangeStart: Instant, + rangeEnd: Instant, + ): Map> { + 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 { + 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 { 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) { 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 = emptyList(), + val attachments: List = 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) diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarScreen.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarScreen.kt index d5a85d4..e82f65f 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarScreen.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarScreen.kt @@ -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) + }, ) } }, diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt index 0165334..c3285fe 100644 --- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt @@ -45,6 +45,23 @@ data class CalendarUiState( val unscheduledTasks: List = emptyList(), val attendeeSuggestions: List = emptyList(), val locationSuggestions: List = emptyList(), + val talkPickerOpen: Boolean = false, + val talkPickerLoading: Boolean = false, + val talkRooms: List = emptyList(), + val attachmentUploading: Boolean = false, + val editCalendarOpen: Boolean = false, + val editCalendarTarget: CalendarBookItem? = null, + val editCalendarShares: List = emptyList(), + val shareeSuggestions: List = emptyList(), + val editCalendarBusy: Boolean = false, + val freeBusyOpen: Boolean = false, + val freeBusyLoading: Boolean = false, + val freeBusyError: String? = null, + val freeBusyResult: Map> = emptyMap(), + val filesPickerOpen: Boolean = false, + val filesPickerLoading: Boolean = false, + val filesPickerPath: String = "", + val filesPickerEntries: List = 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) } } diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt new file mode 100644 index 0000000..ac35ded --- /dev/null +++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/RecurrenceRule.kt @@ -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 = 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 + } + } +}