feat(calendar): пакет функционала — событие 1:1 с веб, вложения, повторение, шаринг, free/busy

- Русские месяцы в шапке; нативный 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, конфликты красным
This commit is contained in:
b-dev-mobile
2026-07-10 22:28:15 +00:00
parent 4b000d3b27
commit e6a71de7a6
11 changed files with 2026 additions and 100 deletions
+1
View File
@@ -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
@@ -25,6 +25,19 @@ data class CalendarLocationSuggestion(
val address: String,
)
data class CalendarTalkRoom(
val token: String,
val displayName: String,
val callUrl: String,
)
data class CalendarShareeSuggestion(
val displayName: String,
/** DAV-принципал: principal:principals/users/<id> или groups/<id>. */
val principal: String,
val isGroup: Boolean,
)
data class CalendarUserSettings(
val timezone: String = "automatic",
val showWeekends: Boolean = true,
@@ -106,6 +119,105 @@ class CalendarApiClient {
}
}
/** Email текущего пользователя (для ORGANIZER в iTIP free-busy). */
fun getUserEmail(session: AuthSession): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "user info")
json.optJSONObject("ocs")?.optJSONObject("data")?.optString("email").orEmpty()
}
}
/** Поиск пользователей/групп для шаринга календаря (OCS sharees). */
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> {
if (query.trim().length < 2) return emptyList()
val client = authedClient(session)
val q = java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v1.php/apps/files_sharing/api/v1/sharees" +
"?format=json&itemType=calendar&perPage=10&search=$q"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "sharees")
val data = json.optJSONObject("ocs")?.optJSONObject("data") ?: return emptyList()
val out = mutableListOf<CalendarShareeSuggestion>()
fun collect(arrName: String, isGroup: Boolean) {
val exactWrap = data.optJSONObject("exact")?.optJSONArray(arrName) ?: JSONArray()
val plain = data.optJSONArray(arrName) ?: JSONArray()
for (arr in listOf(exactWrap, plain)) {
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
val id = item.optJSONObject("value")?.optString("shareWith").orEmpty()
if (id.isBlank()) continue
val kind = if (isGroup) "groups" else "users"
out += CalendarShareeSuggestion(
displayName = item.optString("label").ifBlank { id },
principal = "principal:principals/$kind/$id",
isGroup = isGroup,
)
}
}
}
collect("users", isGroup = false)
collect("groups", isGroup = true)
out.distinctBy { it.principal }
}
}
/** Существующие беседы Talk (для выбора комнаты в событии). */
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "list talk rooms")
if (!isOcsSuccess(json.ocsMeta())) return emptyList()
val data = json.optJSONObject("ocs")?.optJSONArray("data") ?: JSONArray()
val out = mutableListOf<CalendarTalkRoom>()
for (i in 0 until data.length()) {
val room = data.optJSONObject(i) ?: continue
val token = room.optString("token")
if (token.isBlank()) continue
val name = room.optString("displayName").ifBlank { room.optString("name") }
out += CalendarTalkRoom(
token = token,
displayName = name.ifBlank { "Беседа" },
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
)
}
out
}
}
/** Создать беседу заданного типа (2 — групповая/публичная, 3 — публичная по ссылке). */
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
val payload = JSONObject()
.put("roomType", roomType)
.put("roomName", roomName.trim().ifBlank { "Встреча" })
.put("readOnly", 0)
.put("listable", 0)
.toString()
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
if (!isOcsSuccess(json.ocsMeta())) error("Не удалось создать беседу Talk")
val token = json.optJSONObject("ocs")?.optJSONObject("data")?.optString("token").orEmpty()
if (token.isBlank()) error("Не удалось создать беседу Talk")
return CalendarTalkRoom(
token = token,
displayName = roomName.trim().ifBlank { "Встреча" },
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
)
}
}
fun createTalkRoom(session: AuthSession, roomName: String): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
@@ -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() }
}
}
@@ -69,6 +69,8 @@ data class CalendarTaskItem(
data class CalendarEventDraft(
val title: String = "",
val date: java.time.LocalDate = java.time.LocalDate.now(),
/** Дата окончания; null = совпадает с датой начала (однодневное событие). */
val endDate: java.time.LocalDate? = null,
val startTime: String = "10:00",
val endTime: String = "11:00",
val allDay: Boolean = false,
@@ -82,9 +84,17 @@ data class CalendarEventDraft(
val classification: EventClassification = EventClassification.PUBLIC,
val attendees: List<CalendarAttendeeItem> = emptyList(),
val attendeeQuery: String = "",
val reminderMinutes: Int = 15,
/** Напоминания в минутах до начала (0 = во время начала); поддерживается несколько. */
val reminders: List<Int> = listOf(15),
val addTalkRoom: Boolean = false,
val talkRoomUrl: String = "",
val attachments: List<CalendarAttachmentItem> = emptyList(),
)
data class CalendarAttachmentItem(
val fileName: String,
val url: String,
val mimeType: String = "",
)
data class CalendarCreateBookDraft(
@@ -1,18 +1,24 @@
package ru.forbion.f7cloud.feature.calendar
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.CalendarAlarmData
import ru.forbion.f7cloud.core.network.CalendarAttachmentData
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
import ru.forbion.f7cloud.core.network.CalendarEventData
import ru.forbion.f7cloud.core.network.CalendarIcs
import ru.forbion.f7cloud.core.network.CalDavClient
import ru.forbion.f7cloud.core.network.DavCalendar
import ru.forbion.f7cloud.core.network.DavClient
import ru.forbion.f7cloud.core.network.DavEvent
import ru.forbion.f7cloud.core.network.DavTask
import ru.forbion.f7cloud.core.network.DavTrashEvent
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.davFileUrl
import ru.forbion.f7cloud.core.network.davFolderUrl
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
@@ -104,13 +110,111 @@ class CalendarRepository(
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> = apiClient.listTalkRooms(session)
private fun davUserId(session: AuthSession): String =
session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
// --- Управление календарём ---
fun updateCalendarProps(session: AuthSession, calendarHref: String, displayName: String?, color: String?) {
CalDavClient.updateCalendarProps(davClient(session), calendarHref, displayName, color)
}
fun deleteCalendar(session: AuthSession, calendarHref: String) {
CalDavClient.deleteCalendar(davClient(session), calendarHref)
}
fun listCalendarShares(session: AuthSession, calendarHref: String): List<CalDavClient.DavSharee> =
CalDavClient.listCalendarShares(davClient(session), calendarHref)
fun shareCalendar(session: AuthSession, calendarHref: String, principal: String, writable: Boolean) {
CalDavClient.shareCalendar(davClient(session), calendarHref, principal, writable)
}
fun unshareCalendar(session: AuthSession, calendarHref: String, principal: String) {
CalDavClient.unshareCalendar(davClient(session), calendarHref, principal)
}
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> =
apiClient.searchSharees(session, query)
/** Занятость участников (email → интервалы) за диапазон. */
fun freeBusy(
session: AuthSession,
attendeeEmails: List<String>,
rangeStart: Instant,
rangeEnd: Instant,
): Map<String, List<CalDavClient.BusyPeriod>> {
val organizer = apiClient.getUserEmail(session)
if (organizer.isBlank()) error("У пользователя не задан email — занятость недоступна")
return CalDavClient.freeBusy(
davClient(session),
session.serverUrl,
davUserId(session),
organizer,
attendeeEmails,
rangeStart,
rangeEnd,
)
}
private fun davClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
/** Загрузка файла с устройства в папку /Calendar и возврат ATTACH-элемента (WebDAV-URL). */
fun uploadAttachment(
session: AuthSession,
fileName: String,
mimeType: String,
bytes: ByteArray,
): CalendarAttachmentItem {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val userId = davUserId(session)
// Папка для вложений календаря; создаём best-effort (уже существует → игнорируем).
runCatching { DavClient.mkcol(client, davFolderUrl(session.serverUrl, userId, "Calendar")) }
val safeName = sanitizeFileName(fileName)
val fileUrl = davFileUrl(session.serverUrl, userId, "Calendar/$safeName")
val media = mimeType.ifBlank { "application/octet-stream" }.toMediaType()
DavClient.put(client, fileUrl, bytes.toRequestBody(media))
return CalendarAttachmentItem(fileName = safeName, url = fileUrl, mimeType = mimeType)
}
/** Список файлов/папок в директории (для пикера «Добавить из файлов»). relativePath — от корня. */
fun listUserFiles(session: AuthSession, relativePath: String): List<DavClient.DavEntry> {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val userId = davUserId(session)
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
val entries = DavClient.propfind(client, folderUrl, depth = 1)
val self = folderUrl.trimEnd('/')
// Убираем саму папку (первый ответ PROPFIND) и скрытые файлы.
return entries
.filter { it.href.trimEnd('/') != self && !it.name.startsWith('.') }
.sortedWith(compareByDescending<DavClient.DavEntry> { it.isDirectory }.thenBy { it.name.lowercase() })
}
/** Полный WebDAV-URL файла (для ATTACH при выборе из Файлов). */
fun fileAttachment(session: AuthSession, relativePath: String, mimeType: String): CalendarAttachmentItem {
val userId = davUserId(session)
val url = davFileUrl(session.serverUrl, userId, relativePath)
return CalendarAttachmentItem(fileName = relativePath.substringAfterLast('/'), url = url, mimeType = mimeType)
}
private fun sanitizeFileName(name: String): String =
name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
.replace(Regex("[\\r\\n\"]"), "_")
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom =
apiClient.createTalkRoomTyped(session, roomName, roomType)
fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
apiClient.addTalkParticipants(session, callUrl, emails)
}
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
val picked = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
// Если выбранная коллекция не принимает VEVENT (список задач) — уводим в событийный дефолт.
val calendar = if (picked.supportsEvents) picked else ctx.defaultCalendar
var talkUrl = draft.talkRoomUrl
if (draft.addTalkRoom && talkUrl.isBlank()) {
talkUrl = apiClient.createTalkRoom(session, draft.title)
@@ -197,10 +301,12 @@ class CalendarRepository(
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
val calendars = CalDavClient.listCalendars(client, base)
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
val default = calendars.firstOrNull { cal ->
// Дефолт под события — только событийная коллекция (не VTODO-список задач), иначе PUT 403.
val eventCals = calendars.filter { it.supportsEvents }.ifEmpty { calendars }
val default = eventCals.firstOrNull { cal ->
val name = cal.displayName.lowercase()
name.contains("личн") || name.contains("personal") || name == "calendar"
} ?: calendars.first()
} ?: eventCals.first()
return CalDavContext(client, calendars, default)
}
@@ -264,6 +370,7 @@ class CalendarRepository(
organizerEmail = organizerEmail,
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
source = CalendarEventSource.F7CLOUD,
)
@@ -361,23 +468,27 @@ data class CalendarEventItem(
val organizerEmail: String = "",
val talkRoomUrl: String = "",
val alarms: List<CalendarReminderItem> = emptyList(),
val attachments: List<CalendarAttachmentItem> = emptyList(),
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
)
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
val zone = ZoneId.systemDefault()
// Многодневные события: дата окончания отдельная (не раньше даты начала).
val endDay = (endDate ?: date).coerceAtLeast(date)
val start = if (allDay) {
date.atStartOfDay(zone).toInstant().toEpochMilli()
} else {
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
}
val end = if (allDay) {
date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val endRaw = if (allDay) {
endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
} else {
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
parseDateTime(endDay, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: endDay.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
}
val end = endRaw.coerceAtLeast(start)
val rrule = when {
recurrence != RecurrencePreset.NONE -> recurrence.rrule
customRrule.isNotBlank() -> customRrule
@@ -400,8 +511,9 @@ private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String
attendees = attendees.map {
CalendarAttendeeData(it.email, it.displayName, it.partStat)
},
alarms = if (reminderMinutes > 0) listOf(CalendarAlarmData(reminderMinutes)) else emptyList(),
alarms = reminders.filter { it >= 0 }.distinct().map { CalendarAlarmData(it) },
conferenceUri = conf,
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
)
}
@@ -421,6 +533,7 @@ private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventDa
organizerEmail = organizerEmail,
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
conferenceUri = talkRoomUrl,
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
)
private fun CalendarEventData.toItem(
@@ -448,6 +561,7 @@ private fun CalendarEventData.toItem(
attendees = attendees.map { it.toItem() },
talkRoomUrl = conferenceUri,
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
)
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
@@ -74,6 +74,9 @@ fun CalendarScreen(
vm.updateEventDraft { it } // no-op; snack via error path if needed
}
}
val attachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) vm.uploadDeviceAttachment(uri)
}
fun hasCalendarPermission(): Boolean {
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
@@ -220,6 +223,48 @@ fun CalendarScreen(
onRemoveAttendee = vm::removeAttendee,
onSearchLocations = vm::searchLocations,
onApplyLocation = vm::applyLocationSuggestion,
onOpenTalkPicker = vm::openTalkPicker,
onPickDeviceAttachment = { attachmentLauncher.launch("*/*") },
onPickFilesAttachment = vm::openFilesPicker,
onRemoveAttachment = vm::removeAttachment,
onOpenFreeBusy = vm::openFreeBusy,
)
}
if (state.freeBusyOpen) {
CalendarFreeBusyDialog(
state = state,
onDismiss = vm::closeFreeBusy,
)
}
if (state.talkPickerOpen) {
CalendarTalkPickerDialog(
state = state,
onDismiss = vm::closeTalkPicker,
onSelect = vm::selectTalkRoom,
onCreate = vm::createTalkConversation,
)
}
if (state.filesPickerOpen) {
CalendarFilesPickerDialog(
state = state,
onDismiss = vm::closeFilesPicker,
onNavigate = vm::navigateFilesPicker,
onPick = vm::pickFileAttachment,
)
}
if (state.editCalendarOpen) {
CalendarEditBookDialog(
state = state,
onDismiss = vm::closeEditCalendar,
onSave = vm::saveCalendarProps,
onDelete = vm::deleteCalendarConfirmed,
onSearchSharees = vm::searchSharees,
onAddSharee = vm::addSharee,
onRemoveSharee = vm::removeSharee,
)
}
@@ -287,6 +332,11 @@ fun CalendarScreen(
onSyncNow = {
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
},
onEditCalendar = { cal ->
onSidebarOpenChange(false)
scope.launch { drawerState.close() }
vm.openEditCalendar(cal)
},
)
}
},
@@ -45,6 +45,23 @@ data class CalendarUiState(
val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
val talkPickerOpen: Boolean = false,
val talkPickerLoading: Boolean = false,
val talkRooms: List<CalendarTalkRoom> = emptyList(),
val attachmentUploading: Boolean = false,
val editCalendarOpen: Boolean = false,
val editCalendarTarget: CalendarBookItem? = null,
val editCalendarShares: List<ru.forbion.f7cloud.core.network.CalDavClient.DavSharee> = emptyList(),
val shareeSuggestions: List<CalendarShareeSuggestion> = emptyList(),
val editCalendarBusy: Boolean = false,
val freeBusyOpen: Boolean = false,
val freeBusyLoading: Boolean = false,
val freeBusyError: String? = null,
val freeBusyResult: Map<String, List<ru.forbion.f7cloud.core.network.CalDavClient.BusyPeriod>> = emptyMap(),
val filesPickerOpen: Boolean = false,
val filesPickerLoading: Boolean = false,
val filesPickerPath: String = "",
val filesPickerEntries: List<ru.forbion.f7cloud.core.network.DavClient.DavEntry> = emptyList(),
val userSettings: CalendarUserSettings = CalendarUserSettings(),
val saving: Boolean = false,
val deleting: Boolean = false,
@@ -362,7 +379,7 @@ class CalendarViewModel(
eventDraft = CalendarEventDraft(
date = targetDay,
calendarHref = createHref,
reminderMinutes = _state.value.userSettings.defaultReminderMinutes,
reminders = listOf(_state.value.userSettings.defaultReminderMinutes),
),
)
}
@@ -410,6 +427,212 @@ class CalendarViewModel(
_state.value = _state.value.copy(locationSuggestions = emptyList())
}
fun openTalkPicker() {
val s = session ?: return
_state.value = _state.value.copy(talkPickerOpen = true, talkPickerLoading = true, talkRooms = emptyList())
viewModelScope.launch(Dispatchers.IO) {
val rooms = runCatching { repository.listTalkRooms(s) }.getOrDefault(emptyList())
_state.value = _state.value.copy(talkRooms = rooms, talkPickerLoading = false)
}
}
fun closeTalkPicker() { _state.value = _state.value.copy(talkPickerOpen = false) }
fun selectTalkRoom(room: CalendarTalkRoom) {
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
_state.value = _state.value.copy(talkPickerOpen = false)
}
/** roomType: 2 — публичная беседа, 3 — приватная (в терминах spreed). */
fun createTalkConversation(roomType: Int) {
val s = session ?: return
val name = _state.value.eventDraft.title.ifBlank { "Встреча" }
_state.value = _state.value.copy(talkPickerLoading = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.createTalkRoomTyped(s, name, roomType) }
.onSuccess { room ->
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
_state.value = _state.value.copy(talkPickerOpen = false, talkPickerLoading = false)
}
.onFailure { _state.value = _state.value.copy(talkPickerLoading = false, snackMessage = "Не удалось создать беседу") }
}
}
// --- Управление календарём ---
fun openEditCalendar(cal: CalendarBookItem) {
val s = session ?: return
_state.value = _state.value.copy(
editCalendarOpen = true,
editCalendarTarget = cal,
editCalendarShares = emptyList(),
shareeSuggestions = emptyList(),
)
viewModelScope.launch(Dispatchers.IO) {
val shares = runCatching { repository.listCalendarShares(s, cal.href) }.getOrDefault(emptyList())
_state.value = _state.value.copy(editCalendarShares = shares)
}
}
fun closeEditCalendar() {
_state.value = _state.value.copy(editCalendarOpen = false, editCalendarTarget = null, shareeSuggestions = emptyList())
}
fun saveCalendarProps(displayName: String, color: String?) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
_state.value = _state.value.copy(editCalendarBusy = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.updateCalendarProps(
s,
target.href,
displayName.trim().takeIf { it.isNotBlank() && it != target.displayName },
color?.takeIf { it != target.color },
)
}
.onSuccess {
loadCalendars(s)
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь обновлён")
}
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось обновить: ${it.message}") }
}
}
fun deleteCalendarConfirmed() {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
_state.value = _state.value.copy(editCalendarBusy = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.deleteCalendar(s, target.href) }
.onSuccess {
loadCalendars(s)
reloadEvents(s)
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь удалён")
}
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось удалить: ${it.message}") }
}
}
fun searchSharees(query: String) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
val found = runCatching { repository.searchSharees(s, query) }.getOrDefault(emptyList())
_state.value = _state.value.copy(shareeSuggestions = found)
}
}
fun addSharee(sharee: CalendarShareeSuggestion, writable: Boolean) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.shareCalendar(s, target.href, sharee.principal, writable) }
.onSuccess {
val shares = runCatching { repository.listCalendarShares(s, target.href) }.getOrDefault(emptyList())
_state.value = _state.value.copy(editCalendarShares = shares, shareeSuggestions = emptyList())
}
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось пошарить: ${it.message}") }
}
}
fun removeSharee(principal: String) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.unshareCalendar(s, target.href, principal) }
.onSuccess {
_state.value = _state.value.copy(
editCalendarShares = _state.value.editCalendarShares.filterNot { it.principal == principal },
)
}
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось убрать доступ: ${it.message}") }
}
}
// --- Занятость участников (free/busy) ---
fun openFreeBusy() {
val s = session ?: return
val draft = _state.value.eventDraft
val emails = draft.attendees.map { it.email }.filter { it.isNotBlank() }
if (emails.isEmpty()) return
_state.value = _state.value.copy(freeBusyOpen = true, freeBusyLoading = true, freeBusyError = null, freeBusyResult = emptyMap())
val zone = ZoneId.systemDefault()
// Диапазон — весь день события (локальные сутки).
val start = draft.date.atStartOfDay(zone).toInstant()
val end = draft.date.plusDays(1).atStartOfDay(zone).toInstant()
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.freeBusy(s, emails, start, end) }
.onSuccess { _state.value = _state.value.copy(freeBusyResult = it, freeBusyLoading = false) }
.onFailure { _state.value = _state.value.copy(freeBusyLoading = false, freeBusyError = it.message) }
}
}
fun closeFreeBusy() { _state.value = _state.value.copy(freeBusyOpen = false) }
// --- Вложения ---
fun uploadDeviceAttachment(uri: android.net.Uri) {
val s = session ?: return
_state.value = _state.value.copy(attachmentUploading = true)
viewModelScope.launch(Dispatchers.IO) {
val resolver = appContext.contentResolver
val name = queryDisplayName(uri) ?: "attachment"
val mime = resolver.getType(uri).orEmpty()
val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
if (bytes == null || bytes.isEmpty()) {
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Не удалось прочитать файл")
return@launch
}
runCatching { repository.uploadAttachment(s, name, mime, bytes) }
.onSuccess { item ->
updateEventDraft { it.copy(attachments = it.attachments + item) }
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Файл загружен")
}
.onFailure { _state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Ошибка загрузки: ${it.message}") }
}
}
fun openFilesPicker() {
_state.value = _state.value.copy(filesPickerOpen = true)
loadFilesPicker("")
}
fun closeFilesPicker() { _state.value = _state.value.copy(filesPickerOpen = false) }
fun navigateFilesPicker(path: String) = loadFilesPicker(path)
private fun loadFilesPicker(path: String) {
val s = session ?: return
_state.value = _state.value.copy(filesPickerLoading = true, filesPickerPath = path)
viewModelScope.launch(Dispatchers.IO) {
val entries = runCatching { repository.listUserFiles(s, path) }.getOrDefault(emptyList())
_state.value = _state.value.copy(filesPickerEntries = entries, filesPickerLoading = false)
}
}
fun pickFileAttachment(entry: ru.forbion.f7cloud.core.network.DavClient.DavEntry) {
val s = session ?: return
val path = _state.value.filesPickerPath
val rel = if (path.isBlank()) entry.name else "$path/${entry.name}"
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.fileAttachment(s, rel, entry.mimeType.orEmpty()) }
.onSuccess { item ->
updateEventDraft { it.copy(attachments = it.attachments + item) }
_state.value = _state.value.copy(filesPickerOpen = false, snackMessage = "Файл прикреплён")
}
}
}
fun removeAttachment(url: String) {
updateEventDraft { d -> d.copy(attachments = d.attachments.filterNot { it.url == url }) }
}
private fun queryDisplayName(uri: android.net.Uri): String? =
runCatching {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
}
}.getOrNull() ?: uri.lastPathSegment
fun openEventDetail(event: CalendarEventItem) {
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
}
@@ -428,6 +651,11 @@ class CalendarViewModel(
eventDraft = CalendarEventDraft(
title = event.summary,
date = start.toLocalDate(),
// DTEND эксклюзивен для all-day → показываем последний включённый день.
endDate = run {
val endDay = if (event.allDay) end.toLocalDate().minusDays(1) else end.toLocalDate()
endDay.coerceAtLeast(start.toLocalDate()).takeIf { it != start.toLocalDate() }
},
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
allDay = event.allDay,
@@ -440,9 +668,10 @@ class CalendarViewModel(
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
attendees = event.attendees,
reminderMinutes = event.alarms.firstOrNull()?.minutesBefore ?: 15,
reminders = event.alarms.map { it.minutesBefore }.ifEmpty { emptyList() },
talkRoomUrl = event.talkRoomUrl,
addTalkRoom = event.talkRoomUrl.isNotBlank(),
attachments = event.attachments,
),
focusedEventUid = event.uid,
)
@@ -541,10 +770,10 @@ class CalendarViewModel(
CalendarViewMode.LIST -> "Предстоящие события"
CalendarViewMode.WEEK -> {
val (start, end) = CalendarRepository.weekRange(st.selectedDay)
"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"
"${start.format(DateTimeFormatter.ofPattern("d MMM", RU))} ${end.format(DateTimeFormatter.ofPattern("d MMM yyyy", RU))}"
}
CalendarViewMode.DAY, CalendarViewMode.MONTH ->
st.selectedDay.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))
st.selectedDay.format(DateTimeFormatter.ofPattern("d MMMM yyyy", RU))
}
}
@@ -642,6 +871,7 @@ class CalendarViewModel(
companion object {
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
private val RU: Locale = Locale.forLanguageTag("ru")
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", RU)
}
}
@@ -0,0 +1,88 @@
package ru.forbion.f7cloud.feature.calendar
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/** Разобранное правило повторения (подмножество RFC 5545 RRULE, как в веб-версии). */
data class RecurrenceRule(
val freq: String = "WEEKLY", // DAILY | WEEKLY | MONTHLY | YEARLY
val interval: Int = 1,
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
val byDays: Set<String> = emptySet(),
val endType: EndType = EndType.NEVER,
val untilDate: LocalDate? = null,
val count: Int = 10,
) {
enum class EndType { NEVER, UNTIL, COUNT }
fun toRrule(): String = buildString {
append("FREQ=$freq")
if (interval > 1) append(";INTERVAL=$interval")
if (freq == "WEEKLY" && byDays.isNotEmpty()) {
append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}")
}
when (endType) {
EndType.UNTIL -> untilDate?.let { append(";UNTIL=${it.format(UNTIL_FMT)}T235959Z") }
EndType.COUNT -> append(";COUNT=${count.coerceAtLeast(1)}")
EndType.NEVER -> Unit
}
}
companion object {
val ICS_DAYS = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU")
val DAY_LABELS = mapOf(
"MO" to "Пн", "TU" to "Вт", "WE" to "Ср", "TH" to "Чт",
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
)
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */
fun parse(rrule: String): RecurrenceRule? {
if (rrule.isBlank()) return null
val parts = rrule.trim().removePrefix("RRULE:").split(';')
.mapNotNull { p ->
val idx = p.indexOf('=')
if (idx <= 0) null else p.substring(0, idx).uppercase() to p.substring(idx + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase() ?: return null
if (freq !in setOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY")) return null
val until = parts["UNTIL"]?.let { raw ->
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
}
val count = parts["COUNT"]?.toIntOrNull()
return RecurrenceRule(
freq = freq,
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() }
?.filter { it in ICS_DAYS }?.toSet().orEmpty(),
endType = when {
until != null -> EndType.UNTIL
count != null -> EndType.COUNT
else -> EndType.NEVER
},
untilDate = until,
count = count ?: 10,
)
}
/** Человекочитаемое описание правила по-русски (для пилюли «Повторение»). */
fun describe(rrule: String): String {
val rule = parse(rrule) ?: return "Не повторять"
val freqLabel = when (rule.freq) {
"DAILY" -> if (rule.interval == 1) "Каждый день" else "Каждые ${rule.interval} дн."
"WEEKLY" -> if (rule.interval == 1) "Каждую неделю" else "Каждые ${rule.interval} нед."
"MONTHLY" -> if (rule.interval == 1) "Каждый месяц" else "Каждые ${rule.interval} мес."
else -> if (rule.interval == 1) "Каждый год" else "Каждые ${rule.interval} г."
}
val days = if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
" по " + ICS_DAYS.filter { it in rule.byDays }.joinToString(", ") { DAY_LABELS[it] ?: it }
} else ""
val end = when (rule.endType) {
EndType.UNTIL -> rule.untilDate?.let { " до ${it.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}" }.orEmpty()
EndType.COUNT -> ", ${rule.count} раз"
EndType.NEVER -> ""
}
return freqLabel + days + end
}
}
}