Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,163 @@
package ru.forbion.f7cloud.feature.calendar
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.isOcsSuccess
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonObject
data class CalendarAttendeeSuggestion(
val name: String,
val email: String,
val type: String,
)
data class CalendarLocationSuggestion(
val name: String,
val address: String,
)
data class CalendarUserSettings(
val timezone: String = "automatic",
val showWeekends: Boolean = true,
val showWeekNumbers: Boolean = false,
val defaultReminderMinutes: Int = 15,
val showTasks: Boolean = true,
val tasksSidebar: Boolean = true,
)
class CalendarApiClient {
fun searchAttendees(session: AuthSession, query: String): List<CalendarAttendeeSuggestion> {
if (query.trim().length < 2) return emptyList()
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/attendee"
val body = JSONObject().put("search", query.trim()).toString()
val request = Request.Builder()
.url(url)
.post(body.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete attendee")
val data = json.optJSONArray("data") ?: json.optJSONObject("ocs")?.optJSONArray("data") ?: return emptyList()
return buildList {
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
val emails = item.optJSONArray("emails")
val email = emails?.optString(0).orEmpty()
if (email.isBlank()) continue
add(
CalendarAttendeeSuggestion(
name = item.optString("name", email),
email = email,
type = item.optString("type", "individual"),
),
)
}
}
}
}
fun searchLocations(session: AuthSession, query: String): List<CalendarLocationSuggestion> {
if (query.trim().length < 2) return emptyList()
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/location?search=${java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())}"
val request = Request.Builder()
.url(url)
.get()
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete location")
val data = json.optJSONArray("data") ?: return emptyList()
return buildList {
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
add(
CalendarLocationSuggestion(
name = item.optString("name", ""),
address = item.optString("address", item.optString("label", "")),
),
)
}
}
}
}
fun setConfig(session: AuthSession, key: String, value: String) {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/config/$key"
val body = JSONObject().put("value", value).toString()
val request = Request.Builder()
.url(url)
.post(body.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Calendar config failed HTTP ${response.code}")
}
}
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"
val payload = JSONObject()
.put("roomType", 2)
.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 ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
val meta = json.ocsMeta()
if (!isOcsSuccess(meta)) error("Не удалось создать комнату Talk")
val token = json.optJSONObject("ocs")?.optJSONObject("data")?.optString("token").orEmpty()
if (token.isBlank()) error("Не удалось создать комнату Talk")
return "${session.serverUrl.trimEnd('/')}/call/$token"
}
}
fun addTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
val token = callUrl.substringAfter("/call/").substringBefore('?').substringBefore('#')
if (token.isBlank()) return
val client = authedClient(session)
emails.forEach { email ->
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room/$token/participants?format=json"
val payload = JSONObject()
.put("newParticipant", email)
.put("source", "emails")
.toString()
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
runCatching {
client.newCall(request).execute().use { /* best effort */ }
}
}
}
private fun authedClient(session: AuthSession) = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
@@ -0,0 +1,290 @@
package ru.forbion.f7cloud.feature.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.YearMonth
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarYearView(
year: Int,
selectedDay: java.time.LocalDate,
events: List<CalendarEventItem>,
onSelectMonth: (YearMonth) -> Unit,
modifier: Modifier = Modifier,
) {
val months = CalendarRepository.yearMonths(year)
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
months.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
row.forEach { month ->
val count = events.count { event ->
val day = java.time.Instant.ofEpochMilli(event.startEpochMilli)
.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
day.year == month.year && day.month == month.month
}
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(if (month == YearMonth.from(selectedDay)) F7Colors.PrimaryLight else F7Colors.Surface)
.clickable { onSelectMonth(month) }
.padding(10.dp),
) {
Text(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
Text("$count событ.", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTrashSheet(
items: List<CalendarTrashItem>,
onDismiss: () -> Unit,
onRestore: (CalendarTrashItem) -> Unit,
onPurge: (CalendarTrashItem) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Корзина", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (items.isEmpty()) Text("Корзина пуста", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))
items.forEach { item ->
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Text(item.summary, fontWeight = FontWeight.SemiBold)
Text(item.calendarUri, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
F7TextButton(text = "Восстановить", onClick = { onRestore(item) })
F7TextButton(text = "Удалить навсегда", onClick = { onPurge(item) })
}
}
HorizontalDivider(color = F7Colors.Border)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTasksSheet(
tasks: List<CalendarTaskItem>,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Незапланированные задачи", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (tasks.isEmpty()) Text("Нет задач без срока", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))
tasks.forEach { task ->
Column(Modifier.padding(vertical = 8.dp)) {
Text(task.summary, fontWeight = FontWeight.Medium)
Text(task.calendarName, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)
}
HorizontalDivider(color = F7Colors.Border)
}
}
}
}
@Composable
fun CalendarCreateBookDialog(
draft: CalendarCreateBookDraft,
saving: Boolean,
onDismiss: () -> Unit,
onDraftChange: (CalendarCreateBookDraft) -> Unit,
onSave: () -> Unit,
) {
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text("Добавить календарь") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
CreateBookMode.entries.forEach { mode ->
Row(Modifier.fillMaxWidth().clickable { onDraftChange(draft.copy(mode = mode)) }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = draft.mode == mode, onClick = { onDraftChange(draft.copy(mode = mode)) })
Text(
when (mode) {
CreateBookMode.NEW -> "Новый календарь"
CreateBookMode.WITH_TASKS -> "Календарь со списком задач"
CreateBookMode.SUBSCRIBE -> "Подписка по ссылке"
},
modifier = Modifier.padding(start = 4.dp),
)
}
}
F7OutlinedField(value = draft.name, onValueChange = { onDraftChange(draft.copy(name = it)) }, label = "Название")
if (draft.mode == CreateBookMode.SUBSCRIBE) {
F7OutlinedField(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")
}
}
},
confirmButton = { F7PrimaryButton(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving) },
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarAppSettingsSheet(
settings: CalendarUserSettings,
onDismiss: () -> Unit,
onChange: (CalendarUserSettings) -> Unit,
onImportClick: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Параметры календаря", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
SettingsSwitch("Показывать выходные", settings.showWeekends) { onChange(settings.copy(showWeekends = it)) }
SettingsSwitch("Номера недель", settings.showWeekNumbers) { onChange(settings.copy(showWeekNumbers = it)) }
SettingsSwitch("Показывать задачи", settings.showTasks) { onChange(settings.copy(showTasks = it)) }
SettingsSwitch("Боковая панель задач", settings.tasksSidebar) { onChange(settings.copy(tasksSidebar = it)) }
F7OutlinedField(
value = settings.defaultReminderMinutes.toString(),
onValueChange = { v -> v.toIntOrNull()?.let { onChange(settings.copy(defaultReminderMinutes = it)) } },
label = "Напоминание по умолчанию (мин)",
)
F7SecondaryButton(text = "Импорт .ics", onClick = onImportClick)
}
}
}
@Composable
private fun SettingsSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(label)
Switch(checked = checked, onCheckedChange = onChecked)
}
}
@Composable
fun CalendarFullEventEditorDialog(
state: CalendarUiState,
onDismiss: () -> Unit,
onDraftChange: (CalendarEventDraft) -> Unit,
onSave: () -> Unit,
onDelete: () -> Unit,
onSearchAttendees: (String) -> Unit,
onAddAttendee: (CalendarAttendeeSuggestion) -> Unit,
onRemoveAttendee: (String) -> Unit,
onSearchLocations: (String) -> Unit,
onApplyLocation: (CalendarLocationSuggestion) -> 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)) })
}
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 = "Окончание")
}
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))
}
}
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))
}
}
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))
}
}
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) })
}
}
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))
}
}
}
}
},
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("Отмена") }
}
},
)
}
@@ -0,0 +1,101 @@
package ru.forbion.f7cloud.feature.calendar
import ru.forbion.f7cloud.core.network.CalendarAlarmData
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
enum class CalendarViewMode(val label: String) {
DAY("День"),
WEEK("Неделя"),
MONTH("Месяц"),
YEAR("Год"),
LIST("Список"),
}
enum class CalendarEditorMode {
CREATE,
EDIT,
}
enum class RecurrencePreset(val label: String, val rrule: String) {
NONE("Не повторяется", ""),
DAILY("Каждый день", "FREQ=DAILY"),
WEEKLY("Каждую неделю", "FREQ=WEEKLY"),
MONTHLY("Каждый месяц", "FREQ=MONTHLY"),
YEARLY("Каждый год", "FREQ=YEARLY"),
}
enum class EventStatus(val label: String, val icsValue: String) {
CONFIRMED("Подтверждено", "CONFIRMED"),
TENTATIVE("Предварительно", "TENTATIVE"),
CANCELLED("Отменено", "CANCELLED"),
}
enum class EventClassification(val label: String, val icsValue: String) {
PUBLIC("Публичное", "PUBLIC"),
PRIVATE("Приватное", "PRIVATE"),
CONFIDENTIAL("Конфиденциальное", "CONFIDENTIAL"),
}
data class CalendarBookItem(
val href: String,
val displayName: String,
val visible: Boolean,
val isSubscription: Boolean = false,
val color: String? = null,
)
data class CalendarAttendeeItem(
val email: String,
val displayName: String = "",
val partStat: String = "NEEDS-ACTION",
)
data class CalendarTrashItem(
val uid: String,
val href: String,
val etag: String,
val summary: String,
val deletedAt: String,
val calendarUri: String,
)
data class CalendarTaskItem(
val uid: String,
val summary: String,
val due: String,
val calendarName: String,
)
data class CalendarEventDraft(
val title: String = "",
val date: java.time.LocalDate = java.time.LocalDate.now(),
val startTime: String = "10:00",
val endTime: String = "11:00",
val allDay: Boolean = false,
val description: String = "",
val location: String = "",
val calendarHref: String = "",
val recurrence: RecurrencePreset = RecurrencePreset.NONE,
val customRrule: String = "",
val categories: String = "",
val status: EventStatus = EventStatus.CONFIRMED,
val classification: EventClassification = EventClassification.PUBLIC,
val attendees: List<CalendarAttendeeItem> = emptyList(),
val attendeeQuery: String = "",
val reminderMinutes: Int = 15,
val addTalkRoom: Boolean = false,
val talkRoomUrl: String = "",
)
data class CalendarCreateBookDraft(
val name: String = "",
val withTasks: Boolean = false,
val subscriptionUrl: String = "",
val mode: CreateBookMode = CreateBookMode.NEW,
)
enum class CreateBookMode {
NEW,
WITH_TASKS,
SUBSCRIBE,
}
@@ -0,0 +1,63 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
class CalendarPrefsStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun getViewMode(accountKey: String): CalendarViewMode {
val raw = prefs.getString(keyViewMode(accountKey), CalendarViewMode.MONTH.name)
return runCatching { CalendarViewMode.valueOf(raw ?: CalendarViewMode.MONTH.name) }
.getOrDefault(CalendarViewMode.MONTH)
}
fun setViewMode(accountKey: String, mode: CalendarViewMode) {
prefs.edit().putString(keyViewMode(accountKey), mode.name).apply()
}
fun getVisibleCalendarHrefs(accountKey: String): Set<String> {
val raw = prefs.getStringSet(keyVisibleCalendars(accountKey), null)
return raw ?: emptySet()
}
fun setVisibleCalendarHrefs(accountKey: String, hrefs: Set<String>) {
prefs.edit().putStringSet(keyVisibleCalendars(accountKey), hrefs).apply()
}
fun getCreateCalendarHref(accountKey: String): String? =
prefs.getString(keyCreateCalendar(accountKey), null)?.takeIf { it.isNotBlank() }
fun setCreateCalendarHref(accountKey: String, href: String) {
prefs.edit().putString(keyCreateCalendar(accountKey), href).apply()
}
fun getUserSettings(accountKey: String): CalendarUserSettings = CalendarUserSettings(
timezone = prefs.getString(keySetting(accountKey, "timezone"), "automatic") ?: "automatic",
showWeekends = prefs.getBoolean(keySetting(accountKey, "showWeekends"), true),
showWeekNumbers = prefs.getBoolean(keySetting(accountKey, "showWeekNr"), false),
defaultReminderMinutes = prefs.getInt(keySetting(accountKey, "defaultReminder"), 15),
showTasks = prefs.getBoolean(keySetting(accountKey, "showTasks"), true),
tasksSidebar = prefs.getBoolean(keySetting(accountKey, "tasksSidebar"), true),
)
fun setUserSettings(accountKey: String, settings: CalendarUserSettings) {
prefs.edit()
.putString(keySetting(accountKey, "timezone"), settings.timezone)
.putBoolean(keySetting(accountKey, "showWeekends"), settings.showWeekends)
.putBoolean(keySetting(accountKey, "showWeekNr"), settings.showWeekNumbers)
.putInt(keySetting(accountKey, "defaultReminder"), settings.defaultReminderMinutes)
.putBoolean(keySetting(accountKey, "showTasks"), settings.showTasks)
.putBoolean(keySetting(accountKey, "tasksSidebar"), settings.tasksSidebar)
.apply()
}
private fun keySetting(account: String, key: String) = "setting_${key}_$account"
companion object {
private const val PREFS = "f7_calendar_prefs"
private fun keyViewMode(account: String) = "view_mode::$account"
private fun keyVisibleCalendars(account: String) = "visible_cals::$account"
private fun keyCreateCalendar(account: String) = "create_cal::$account"
}
}
@@ -0,0 +1,446 @@
package ru.forbion.f7cloud.feature.calendar
import okhttp3.OkHttpClient
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.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.DavEvent
import ru.forbion.f7cloud.core.network.DavTask
import ru.forbion.f7cloud.core.network.DavTrashEvent
import ru.forbion.f7cloud.core.network.NetworkFactory
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.ZoneOffset
class CalendarRepository(
private val apiClient: CalendarApiClient = CalendarApiClient(),
) {
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
fun loadMonth(session: AuthSession, month: YearMonth, visibleHrefs: Set<String>): List<CalendarEventItem> {
return loadRange(session, monthGridStart(month), monthGridEnd(month), visibleHrefs)
}
fun loadRange(
session: AuthSession,
rangeStart: LocalDate,
rangeEnd: LocalDate,
visibleHrefs: Set<String>,
): List<CalendarEventItem> {
val ctx = openDavContext(session)
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs)
}
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
val ctx = openDavContext(session)
val out = mutableListOf<CalendarTaskItem>()
for (cal in ctx.calendars.take(8)) {
runCatching { CalDavClient.queryTasks(ctx.client, cal) }
.onSuccess { tasks ->
out += tasks.filter { !it.isCompleted && it.dueRaw.isBlank() }
.map { it.toTaskItem() }
}
}
return out.distinctBy { it.uid }
}
fun loadTrash(session: AuthSession): List<CalendarTrashItem> {
val ctx = openDavContext(session)
val userBase = CalDavClient.calendarsBase(
session.serverUrl,
session.davUserId ?: OcsUserResolver.resolveDavUserId(session),
)
return CalDavClient.listTrash(ctx.client, userBase).map { it.toTrashItem() }
}
fun restoreTrashItem(session: AuthSession, item: CalendarTrashItem) {
val ctx = openDavContext(session)
val name = item.href.substringAfterLast('/')
CalDavClient.restoreTrashEvent(ctx.client, item.href, name)
}
fun purgeTrashItem(session: AuthSession, item: CalendarTrashItem) {
val ctx = openDavContext(session)
CalDavClient.purgeTrashEvent(ctx.client, item.href, item.etag.ifBlank { "*" })
}
fun createCalendar(session: AuthSession, name: String, withTasks: Boolean): CalendarBookItem {
val ctx = openDavContext(session)
val uri = slugify(name) + "-" + System.currentTimeMillis().toString(36)
val cal = CalDavClient.createCalendar(ctx.client, calendarsRoot(session), uri, name, withTasks)
return CalendarBookItem(cal.href, cal.displayName, visible = true, isSubscription = false)
}
fun subscribeCalendar(session: AuthSession, name: String, sourceUrl: String): CalendarBookItem {
val ctx = openDavContext(session)
val uri = "sub-" + System.currentTimeMillis().toString(36)
val cal = CalDavClient.createSubscription(ctx.client, calendarsRoot(session), uri, name, sourceUrl)
return CalendarBookItem(cal.href, cal.displayName, visible = true, isSubscription = true)
}
fun importIcs(session: AuthSession, calendarHref: String, icsContent: String): Int {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == calendarHref } ?: ctx.defaultCalendar
return CalDavClient.importEvents(ctx.client, calendar, icsContent)
}
fun searchAttendees(session: AuthSession, query: String) = apiClient.searchAttendees(session, query)
fun searchLocations(session: AuthSession, query: String) = apiClient.searchLocations(session, query)
fun saveUserSetting(session: AuthSession, key: String, value: String) = apiClient.setConfig(session, key, value)
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
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
var talkUrl = draft.talkRoomUrl
if (draft.addTalkRoom && talkUrl.isBlank()) {
talkUrl = apiClient.createTalkRoom(session, draft.title)
val emails = draft.attendees.map { it.email }.filter { it.isNotBlank() }
if (emails.isNotEmpty()) apiClient.addTalkParticipants(session, talkUrl, emails)
}
val data = draft.toEventData(existing?.uid, talkUrl)
if (existing == null) {
val url = calendar.href.trimEnd('/') + "/${data.uid}.ics"
CalDavClient.putEventIcs(ctx.client, url, null, CalendarIcs.build(data))
return data.toItem(calendar.displayName, calendar.href, url, "")
} else {
CalDavClient.updateEvent(ctx.client, calendar, existing.href, existing.etag, data)
return existing.copy(
summary = data.summary,
startEpochMilli = data.startEpochMilli,
endEpochMilli = data.endEpochMilli,
allDay = data.allDay,
description = data.description,
location = data.location.ifBlank { talkUrl },
rrule = data.rrule,
categories = data.categories,
status = data.status,
classification = data.classification,
attendees = data.attendees.map { it.toItem() },
talkRoomUrl = talkUrl,
alarms = data.alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
)
}
}
fun deleteEvent(session: AuthSession, event: CalendarEventItem) {
val ctx = openDavContext(session)
CalDavClient.deleteEvent(ctx.client, event.href, event.etag.ifBlank { "*" })
}
fun duplicateEvent(session: AuthSession, event: CalendarEventItem, day: LocalDate): CalendarEventItem {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == event.calendarHref } ?: ctx.defaultCalendar
val zone = ZoneId.systemDefault()
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone)
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone)
val duration = java.time.Duration.between(start, end)
val newStart = if (event.allDay) day.atStartOfDay(zone) else day.atTime(start.toLocalTime()).atZone(zone)
val newEnd = if (event.allDay) day.plusDays(1).atStartOfDay(zone) else newStart.plus(duration)
val data = CalendarEventData(
uid = CalendarIcs.newUid(),
summary = event.summary,
description = event.description,
location = event.location,
startEpochMilli = newStart.toInstant().toEpochMilli(),
endEpochMilli = newEnd.toInstant().toEpochMilli(),
allDay = event.allDay,
rrule = event.rrule,
categories = event.categories,
status = event.status,
classification = event.classification,
attendees = event.attendees.map { it.toData() },
alarms = event.alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
conferenceUri = event.talkRoomUrl,
)
val url = calendar.href.trimEnd('/') + "/${data.uid}.ics"
CalDavClient.putEventIcs(ctx.client, url, null, CalendarIcs.build(data))
return data.toItem(calendar.displayName, calendar.href, url, "")
}
fun respondToInvite(session: AuthSession, event: CalendarEventItem, partStat: String, userEmail: String) {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == event.calendarHref } ?: ctx.defaultCalendar
val attendees = event.attendees.map {
if (it.email.equals(userEmail, ignoreCase = true)) it.copy(partStat = partStat) else it
}
val data = event.toEventData().copy(attendees = attendees.map { it.toData() })
CalDavClient.updateEvent(ctx.client, calendar, event.href, event.etag, data)
}
fun openDavContext(session: AuthSession): CalDavContext {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
val calendars = CalDavClient.listCalendars(client, base)
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
val default = calendars.firstOrNull { cal ->
val name = cal.displayName.lowercase()
name.contains("личн") || name.contains("personal") || name == "calendar"
} ?: calendars.first()
return CalDavContext(client, calendars, default)
}
private fun calendarsRoot(session: AuthSession): String =
CalDavClient.calendarsBase(session.serverUrl, session.davUserId ?: OcsUserResolver.resolveDavUserId(session))
private fun fetchEvents(
ctx: CalDavContext,
rangeStart: Instant,
rangeEnd: Instant,
visibleHrefs: Set<String>,
): List<CalendarEventItem> {
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
val events = mutableListOf<DavEvent>()
var successCount = 0
var lastError: String? = null
for (cal in calendars.take(12)) {
runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) }
.onSuccess { successCount++; events += it }
.onFailure { lastError = it.message }
}
if (successCount == 0 && lastError != null) {
throw IllegalStateException("Не удалось загрузить события календаря: $lastError")
}
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
}
private fun DavEvent.toItem() = CalendarEventItem(
uid = uid,
href = href,
etag = etag,
summary = summary,
startLabel = startLabel,
startEpochMilli = startEpochMilli,
endEpochMilli = endEpochMilli,
calendarName = calendarName,
calendarHref = calendarHref,
allDay = allDay,
description = description,
location = location,
rrule = rrule,
categories = categories,
status = status,
classification = classification,
attendees = attendees.map { it.toItem() },
organizerEmail = organizerEmail,
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
source = CalendarEventSource.F7CLOUD,
)
private fun DavTrashEvent.toTrashItem() = CalendarTrashItem(
uid = uid,
href = href,
etag = etag,
summary = summary,
deletedAt = deletedAt,
calendarUri = calendarUri,
)
private fun DavTask.toTaskItem() = CalendarTaskItem(
uid = uid,
summary = summary,
due = due,
calendarName = calendarName,
)
private fun slugify(name: String): String =
name.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifBlank { "calendar" }
data class CalDavContext(
val client: OkHttpClient,
val calendars: List<DavCalendar>,
val defaultCalendar: DavCalendar,
)
companion object {
fun monthGridStart(month: YearMonth): LocalDate {
var day = month.atDay(1)
while (day.dayOfWeek.value != 1) day = day.minusDays(1)
return day
}
fun monthGridEnd(month: YearMonth): LocalDate {
var day = month.atEndOfMonth()
while (day.dayOfWeek.value != 7) day = day.plusDays(1)
return day
}
fun monthGridDays(month: YearMonth): List<LocalDate> {
val start = monthGridStart(month)
return (0 until 42).map { start.plusDays(it.toLong()) }
}
fun yearMonths(year: Int): List<YearMonth> = (1..12).map { YearMonth.of(year, it) }
fun weekRange(day: LocalDate): Pair<LocalDate, LocalDate> {
var start = day
while (start.dayOfWeek.value != 1) start = start.minusDays(1)
return start to start.plusDays(6)
}
fun listRange(day: LocalDate): Pair<LocalDate, LocalDate> = day.minusDays(7) to day.plusDays(60)
fun eventOccursOnDay(event: CalendarEventItem, day: LocalDate, zone: ZoneId = ZoneId.systemDefault()): Boolean {
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone).toLocalDate()
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone).toLocalDate()
return if (event.allDay) !day.isBefore(start) && day.isBefore(end) else day == start
}
fun formatEventTime(event: CalendarEventItem, zone: ZoneId = ZoneId.systemDefault()): String {
if (event.allDay) return "Весь день"
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone)
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone)
val fmt = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
return "${start.format(fmt)} ${end.format(fmt)}"
}
}
}
enum class CalendarEventSource { F7CLOUD, GOOGLE }
data class CalendarReminderItem(val minutesBefore: Int, val action: String = "DISPLAY")
data class CalendarEventItem(
val uid: String = "",
val href: String = "",
val etag: String = "",
val summary: String,
val startLabel: String,
val startEpochMilli: Long,
val endEpochMilli: Long,
val calendarName: String,
val calendarHref: String = "",
val allDay: Boolean = false,
val description: String = "",
val location: String = "",
val rrule: String = "",
val categories: List<String> = emptyList(),
val status: String = "CONFIRMED",
val classification: String = "PUBLIC",
val attendees: List<CalendarAttendeeItem> = emptyList(),
val organizerEmail: String = "",
val talkRoomUrl: String = "",
val alarms: List<CalendarReminderItem> = emptyList(),
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
)
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
val zone = ZoneId.systemDefault()
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()
} else {
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
}
val rrule = when {
recurrence != RecurrencePreset.NONE -> recurrence.rrule
customRrule.isNotBlank() -> customRrule
else -> ""
}
val cats = categories.split(',').map { it.trim() }.filter { it.isNotBlank() }
val conf = talkUrl.ifBlank { this.talkRoomUrl }
return CalendarEventData(
uid = uid ?: CalendarIcs.newUid(),
summary = title.trim(),
description = description.trim(),
location = location.trim().ifBlank { conf },
startEpochMilli = start,
endEpochMilli = end,
allDay = allDay,
rrule = rrule,
categories = cats,
status = status.icsValue,
classification = classification.icsValue,
attendees = attendees.map {
CalendarAttendeeData(it.email, it.displayName, it.partStat)
},
alarms = if (reminderMinutes > 0) listOf(CalendarAlarmData(reminderMinutes)) else emptyList(),
conferenceUri = conf,
)
}
private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventData(
uid = uid,
summary = summary,
description = description,
location = location,
startEpochMilli = startEpochMilli,
endEpochMilli = endEpochMilli,
allDay = allDay,
rrule = rrule,
categories = categories,
status = status,
classification = classification,
attendees = attendees.map { it.toData() },
organizerEmail = organizerEmail,
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
conferenceUri = talkRoomUrl,
)
private fun CalendarEventData.toItem(
calendarName: String,
calendarHref: String,
href: String,
etag: String,
) = CalendarEventItem(
uid = uid,
href = href,
etag = etag,
summary = summary,
startLabel = "",
startEpochMilli = startEpochMilli,
endEpochMilli = endEpochMilli,
calendarName = calendarName,
calendarHref = calendarHref,
allDay = allDay,
description = description,
location = location,
rrule = rrule,
categories = categories,
status = status,
classification = classification,
attendees = attendees.map { it.toItem() },
talkRoomUrl = conferenceUri,
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
)
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
private fun CalendarAttendeeItem.toData() = CalendarAttendeeData(email, displayName, partStat)
private fun parseDateTime(day: LocalDate, timeText: String): LocalDateTime? {
val parts = timeText.trim().split(':')
if (parts.size != 2) return null
val hour = parts[0].toIntOrNull() ?: return null
val minute = parts[1].toIntOrNull() ?: return null
if (hour !in 0..23 || minute !in 0..59) return null
return day.atTime(hour, minute)
}
@@ -0,0 +1,471 @@
package ru.forbion.f7cloud.feature.calendar
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarScreen(
session: AuthSession,
modifier: Modifier = Modifier,
focusEventUid: String? = null,
settingsRequest: Int = 0,
sidebarOpen: Boolean = false,
onSidebarOpenChange: (Boolean) -> Unit = {},
onFocusEventConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val context = LocalContext.current
val vm: CalendarViewModel = viewModel(factory = CalendarViewModel.Factory(context))
val state by vm.state.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
vm.onCalendarPermissionResult(result.values.all { it })
}
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
}.onSuccess { content ->
if (!content.isNullOrBlank()) vm.importIcs(content)
}.onFailure {
vm.updateEventDraft { it } // no-op; snack via error path if needed
}
}
fun hasCalendarPermission(): Boolean {
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
val write = ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
return read && write
}
fun requestCalendarPermissions() {
permissionLauncher.launch(
arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR),
)
}
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
LaunchedEffect(sidebarOpen) {
if (sidebarOpen && drawerState.isClosed) drawerState.open()
if (!sidebarOpen && drawerState.isOpen) drawerState.close()
}
LaunchedEffect(drawerState) {
snapshotFlow { drawerState.isOpen }.distinctUntilChanged().collect { open ->
if (open != sidebarOpen) onSidebarOpenChange(open)
}
}
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
vm.bind(session)
}
LaunchedEffect(focusEventUid) {
val uid = focusEventUid ?: return@LaunchedEffect
vm.focusEventByUid(session, uid)
onFocusEventConsumed()
}
LaunchedEffect(settingsRequest) {
if (settingsRequest > 0) vm.openSyncSettings()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
F7OverlayDismissHandler(
enabled = state.eventDetail != null,
onDismiss = vm::closeEventDetail,
)
F7OverlayDismissHandler(
enabled = state.createDialogOpen,
onDismiss = vm::closeCreateDialog,
)
F7OverlayDismissHandler(
enabled = state.settingsOpen,
onDismiss = vm::closeAppSettings,
)
F7OverlayDismissHandler(
enabled = state.syncSettingsOpen,
onDismiss = vm::closeSyncSettings,
)
F7OverlayDismissHandler(
enabled = state.trashOpen,
onDismiss = vm::closeTrash,
)
F7OverlayDismissHandler(
enabled = state.tasksOpen,
onDismiss = vm::closeTasks,
)
F7OverlayDismissHandler(
enabled = state.createBookOpen,
onDismiss = vm::closeCreateBook,
)
F7OverlayDismissHandler(
enabled = state.datePickerOpen,
onDismiss = vm::closeDatePicker,
)
if (state.syncSettingsOpen) {
CalendarSyncSettingsDialog(
state = state,
onDismiss = vm::closeSyncSettings,
onSyncEnabledChange = { enabled ->
if (enabled && !hasCalendarPermission()) requestCalendarPermissions()
vm.setSyncEnabled(enabled)
},
onSelectCalendar = vm::selectDeviceCalendar,
onSyncNow = {
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
},
)
}
if (state.settingsOpen) {
CalendarAppSettingsSheet(
settings = state.userSettings,
onDismiss = vm::closeAppSettings,
onChange = vm::updateUserSettings,
onImportClick = { importLauncher.launch("*/*") },
)
}
if (state.trashOpen) {
CalendarTrashSheet(
items = state.trashItems,
onDismiss = vm::closeTrash,
onRestore = vm::restoreTrash,
onPurge = vm::purgeTrash,
)
}
if (state.tasksOpen) {
CalendarTasksSheet(
tasks = state.unscheduledTasks,
onDismiss = vm::closeTasks,
)
}
if (state.createBookOpen) {
CalendarCreateBookDialog(
draft = state.createBookDraft,
saving = state.saving,
onDismiss = vm::closeCreateBook,
onDraftChange = vm::updateCreateBookDraft,
onSave = vm::saveCreateBook,
)
}
if (state.datePickerOpen) {
CalendarDatePickerDialog(
selectedDay = state.selectedDay,
onDismiss = vm::closeDatePicker,
onConfirm = vm::pickDate,
)
}
if (state.createDialogOpen) {
CalendarFullEventEditorDialog(
state = state,
onDismiss = vm::closeCreateDialog,
onDraftChange = { draft -> vm.updateEventDraft { draft } },
onSave = vm::saveEventDraft,
onDelete = vm::deleteSelectedEvent,
onSearchAttendees = vm::searchAttendees,
onAddAttendee = vm::addAttendee,
onRemoveAttendee = vm::removeAttendee,
onSearchLocations = vm::searchLocations,
onApplyLocation = vm::applyLocationSuggestion,
)
}
state.eventDetail?.let { event ->
CalendarEventDetailSheet(
session = session,
event = event,
onDismiss = vm::closeEventDetail,
onEdit = { vm.openEditEvent(event) },
onDelete = vm::deleteSelectedEvent,
onDuplicate = { vm.duplicateEvent(event) },
onAccept = { vm.respondToInvite("ACCEPTED") },
onDecline = { vm.respondToInvite("DECLINED") },
onTentative = { vm.respondToInvite("TENTATIVE") },
deleting = state.deleting,
)
}
val visibleEvents = vm.visibleEvents()
val eventsByDay = rememberEventsByDay(visibleEvents)
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = true,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.fillMaxWidth(0.86f),
drawerContainerColor = F7Colors.Surface,
) {
CalendarNavigationSidebar(
session = session,
state = state,
periodTitle = vm.periodTitle(),
onPrevious = vm::previousPeriod,
onNext = vm::nextPeriod,
onPeriodClick = vm::openDatePicker,
onCreate = { vm.openCreateDialog() },
onToday = vm::goToToday,
onViewMode = vm::setViewMode,
onDismiss = {
scope.launch { drawerState.close() }
onSidebarOpenChange(false)
},
onToggleCalendar = vm::toggleCalendarVisibility,
onOpenSettings = {
onSidebarOpenChange(false)
vm.openSyncSettings()
},
onOpenAppSettings = {
onSidebarOpenChange(false)
vm.openAppSettings()
},
onOpenTrash = {
onSidebarOpenChange(false)
vm.openTrash()
},
onOpenTasks = {
onSidebarOpenChange(false)
vm.openTasks()
},
onCreateBook = {
onSidebarOpenChange(false)
vm.openCreateBook()
},
onSyncNow = {
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
},
)
}
},
) {
F7ModuleScreen(
modifier = modifier,
loading = state.loading && state.events.isEmpty(),
error = state.error,
) {
if (state.syncEnabled) {
Text(
buildString {
append("Синхронизация с телефоном")
state.lastSyncLabel?.let { append(" · $it") }
if (state.syncing) append(" · идёт…")
},
style = MaterialTheme.typography.labelSmall,
color = F7Colors.PrimaryDark,
)
}
if (state.needsCalendarPermission && state.syncEnabled) {
Text(
"Нужен доступ к календарю телефона",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.Error,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.padding(vertical = 4.dp),
)
}
if (!state.snackMessage.isNullOrBlank()) {
Text(
state.snackMessage!!,
color = F7Colors.PrimaryDark,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(8.dp),
)
LaunchedEffect(state.snackMessage) {
delay(2500)
vm.clearSnack()
}
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
when (state.viewMode) {
CalendarViewMode.MONTH -> {
CalendarMonthGrid(
month = state.visibleMonth,
selectedDay = state.selectedDay,
daysWithEvents = vm.daysWithEvents(),
eventsByDay = eventsByDay,
onSelectDay = vm::selectDay,
)
SelectedDayEvents(
day = state.selectedDay,
events = vm.eventsForDay(state.selectedDay),
focusedEventUid = state.focusedEventUid,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.WEEK -> {
CalendarWeekView(
selectedDay = state.selectedDay,
events = visibleEvents,
onSelectDay = vm::selectDay,
onEventClick = vm::openEventDetail,
)
SelectedDayEvents(
day = state.selectedDay,
events = vm.eventsForDay(state.selectedDay),
focusedEventUid = state.focusedEventUid,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.DAY -> {
CalendarDayTimeline(
session = session,
day = state.selectedDay,
events = vm.eventsForDay(state.selectedDay),
viewMode = state.viewMode,
onViewMode = vm::setViewMode,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.YEAR -> {
CalendarYearView(
year = state.visibleYear,
selectedDay = state.selectedDay,
events = visibleEvents,
onSelectMonth = vm::selectYearMonth,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.LIST -> {
if (!state.loading && visibleEvents.isEmpty()) {
Text("Нет предстоящих событий", color = F7Colors.TextSecondary)
} else {
CalendarListView(
events = visibleEvents,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
@Composable
private fun SelectedDayEvents(
day: java.time.LocalDate,
events: List<CalendarEventItem>,
focusedEventUid: String?,
onEventClick: (CalendarEventItem) -> Unit,
modifier: Modifier = Modifier,
) {
val formatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))
Column(modifier = modifier.fillMaxWidth()) {
Text(
day.format(formatter).replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.forLanguageTag("ru")) else it.toString()
},
style = MaterialTheme.typography.titleSmall,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(top = 4.dp),
)
if (events.isEmpty()) {
Text("Нет событий в этот день", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
} else {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(events, key = { "${it.uid}|${it.startEpochMilli}" }) { event ->
val focused = focusedEventUid?.let { needle ->
event.uid == needle || event.uid.endsWith(needle) || needle.endsWith(event.uid)
} == true
CalendarEventRow(
event = event,
highlighted = focused,
onClick = { onEventClick(event) },
)
}
}
}
}
}
@Composable
private fun rememberEventsByDay(events: List<CalendarEventItem>): Map<java.time.LocalDate, List<CalendarEventItem>> {
return androidx.compose.runtime.remember(events) {
val map = mutableMapOf<java.time.LocalDate, MutableList<CalendarEventItem>>()
events.forEach { event ->
val zone = java.time.ZoneId.systemDefault()
val start = java.time.Instant.ofEpochMilli(event.startEpochMilli).atZone(zone).toLocalDate()
val end = java.time.Instant.ofEpochMilli(event.endEpochMilli).atZone(zone).toLocalDate()
var day = start
while (!day.isAfter(end)) {
if (CalendarRepository.eventOccursOnDay(event, day)) {
map.getOrPut(day) { mutableListOf() }.add(event)
}
day = day.plusDays(1)
if (!event.allDay) break
}
}
map
}
}
@@ -0,0 +1,212 @@
package ru.forbion.f7cloud.feature.calendar
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.CalDavClient
import ru.forbion.f7cloud.core.network.DavEvent
import java.time.Instant
data class CalendarSyncResult(
val pushedToDevice: Int = 0,
val pushedToServer: Int = 0,
val updated: Int = 0,
val deleted: Int = 0,
val linked: Int = 0,
)
class CalendarSyncEngine(
private val repository: CalendarRepository,
private val deviceClient: DeviceCalendarClient,
private val store: CalendarSyncStore,
) {
fun run(
session: AuthSession,
deviceCalendarId: Long,
): CalendarSyncResult {
val accountKey = store.accountKey(session.serverUrl, session.username)
val ctx = repository.openDavContext(session)
val rangeStart = Instant.now().minusSeconds(RANGE_PAST_SEC)
val rangeEnd = Instant.now().plusSeconds(RANGE_FUTURE_SEC)
val ncEvents = CalDavClient.queryEventsInRange(
ctx.client,
ctx.defaultCalendar,
rangeStart,
rangeEnd,
)
val deviceEvents = deviceClient.queryEvents(
deviceCalendarId,
rangeStart.toEpochMilli(),
rangeEnd.toEpochMilli(),
)
var mappings = store.loadMappings(accountKey)
.filter { it.deviceCalendarId == deviceCalendarId }
.toMutableList()
var pushedToDevice = 0
var pushedToServer = 0
var updated = 0
var deleted = 0
var linked = 0
val ncByUid = ncEvents.associateBy { it.uid }
val devById = deviceEvents.associateBy { it.eventId }
// Update linked pairs (last-write-wins)
val mappingIterator = mappings.iterator()
while (mappingIterator.hasNext()) {
val map = mappingIterator.next()
val nc = ncByUid[map.ncUid]
val dev = devById[map.deviceEventId]
when {
nc == null && dev == null -> {
mappingIterator.remove()
deleted++
}
nc == null && dev != null -> {
runCatching { deviceClient.deleteEvent(dev.eventId) }
mappingIterator.remove()
deleted++
}
nc != null && dev == null -> {
runCatching {
CalDavClient.deleteEvent(ctx.client, map.ncHref, map.ncEtag.ifBlank { "*" })
}
mappingIterator.remove()
deleted++
}
nc != null && dev != null -> {
when {
nc.lastModifiedEpochMilli > dev.lastModifiedEpochMilli + SYNC_SKEW_MS -> {
deviceClient.updateEvent(
eventId = dev.eventId,
title = nc.summary,
description = dev.description,
startEpochMilli = nc.startEpochMilli,
endEpochMilli = nc.endEpochMilli,
allDay = nc.allDay,
ncUid = nc.uid,
)
updated++
}
dev.lastModifiedEpochMilli > nc.lastModifiedEpochMilli + SYNC_SKEW_MS -> {
CalDavClient.updateEvent(
client = ctx.client,
calendar = ctx.defaultCalendar,
href = nc.href,
etag = nc.etag.ifBlank { map.ncEtag }.ifBlank { "*" },
uid = nc.uid,
summary = dev.title.ifBlank { nc.summary },
startEpochMilli = dev.startEpochMilli,
endEpochMilli = dev.endEpochMilli,
allDay = dev.allDay,
)
updated++
}
}
val refreshed = mappings.indexOfFirst { it.ncUid == map.ncUid }
if (refreshed >= 0) {
mappings[refreshed] = map.copy(
ncHref = nc.href,
ncEtag = nc.etag,
)
}
}
}
}
val mappedNcUids = mappings.map { it.ncUid }.toSet()
val mappedDevIds = mappings.map { it.deviceEventId }.toSet()
// Fuzzy link: same title + close start
for (nc in ncEvents) {
if (nc.uid in mappedNcUids) continue
val match = deviceEvents.firstOrNull { dev ->
dev.eventId !in mappedDevIds &&
titlesMatch(nc.summary, dev.title) &&
kotlin.math.abs(nc.startEpochMilli - dev.startEpochMilli) < FUZZY_START_MS
} ?: continue
mappings += CalendarSyncMapping(
ncUid = nc.uid,
ncHref = nc.href,
ncEtag = nc.etag,
deviceEventId = match.eventId,
deviceCalendarId = deviceCalendarId,
)
deviceClient.stampNcUid(match.eventId, nc.uid)
linked++
}
val mappedNcUids2 = mappings.map { it.ncUid }.toSet()
val mappedDevIds2 = mappings.map { it.deviceEventId }.toSet()
// NC → device
for (nc in ncEvents) {
if (nc.uid in mappedNcUids2) continue
val eventId = deviceClient.insertEvent(
calendarId = deviceCalendarId,
title = nc.summary,
description = "",
startEpochMilli = nc.startEpochMilli,
endEpochMilli = nc.endEpochMilli,
allDay = nc.allDay,
ncUid = nc.uid,
)
mappings += CalendarSyncMapping(
ncUid = nc.uid,
ncHref = nc.href,
ncEtag = nc.etag,
deviceEventId = eventId,
deviceCalendarId = deviceCalendarId,
)
pushedToDevice++
}
val mappedDevIds3 = mappings.map { it.deviceEventId }.toSet()
// device → NC
for (dev in deviceEvents) {
if (dev.eventId in mappedDevIds3) continue
val uid = dev.ncUid ?: "${java.util.UUID.randomUUID()}@f7cloud.mobile"
val start = java.time.LocalDateTime.ofInstant(
Instant.ofEpochMilli(dev.startEpochMilli),
java.time.ZoneId.systemDefault(),
)
val durationMin = ((dev.endEpochMilli - dev.startEpochMilli) / 60_000).coerceAtLeast(15)
CalDavClient.createEvent(
ctx.client,
ctx.defaultCalendar,
dev.title.ifBlank { "(без названия)" },
start,
durationMin,
)
val href = ctx.defaultCalendar.href.trimEnd('/') + "/$uid.ics"
mappings += CalendarSyncMapping(
ncUid = uid,
ncHref = href,
ncEtag = "",
deviceEventId = dev.eventId,
deviceCalendarId = deviceCalendarId,
)
deviceClient.stampNcUid(dev.eventId, uid)
pushedToServer++
}
store.saveMappings(accountKey, mappings)
store.setLastSyncAt(accountKey, System.currentTimeMillis())
return CalendarSyncResult(
pushedToDevice = pushedToDevice,
pushedToServer = pushedToServer,
updated = updated,
deleted = deleted,
linked = linked,
)
}
private fun titlesMatch(a: String, b: String): Boolean =
a.trim().equals(b.trim(), ignoreCase = true)
companion object {
private const val RANGE_PAST_SEC = 60L * 60 * 24 * 60 // 60 days
private const val RANGE_FUTURE_SEC = 60L * 60 * 24 * 365 // 1 year
private const val FUZZY_START_MS = 120_000L
private const val SYNC_SKEW_MS = 2_000L
}
}
@@ -0,0 +1,86 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
data class CalendarSyncMapping(
val ncUid: String,
val ncHref: String,
val ncEtag: String,
val deviceEventId: Long,
val deviceCalendarId: Long,
)
class CalendarSyncStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun isEnabled(accountKey: String): Boolean =
prefs.getBoolean(keyEnabled(accountKey), false)
fun setEnabled(accountKey: String, enabled: Boolean) {
prefs.edit().putBoolean(keyEnabled(accountKey), enabled).apply()
}
fun getDeviceCalendarId(accountKey: String): Long =
prefs.getLong(keyDeviceCal(accountKey), -1L)
fun setDeviceCalendarId(accountKey: String, calendarId: Long) {
prefs.edit().putLong(keyDeviceCal(accountKey), calendarId).apply()
}
fun getLastSyncAt(accountKey: String): Long =
prefs.getLong(keyLastSync(accountKey), 0L)
fun setLastSyncAt(accountKey: String, epochMilli: Long) {
prefs.edit().putLong(keyLastSync(accountKey), epochMilli).apply()
}
fun loadMappings(accountKey: String): List<CalendarSyncMapping> {
val raw = prefs.getString(keyMappings(accountKey), null) ?: return emptyList()
return runCatching {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
add(
CalendarSyncMapping(
ncUid = o.getString("ncUid"),
ncHref = o.getString("ncHref"),
ncEtag = o.optString("ncEtag", ""),
deviceEventId = o.getLong("deviceEventId"),
deviceCalendarId = o.getLong("deviceCalendarId"),
),
)
}
}
}.getOrDefault(emptyList())
}
fun saveMappings(accountKey: String, mappings: List<CalendarSyncMapping>) {
val arr = JSONArray()
mappings.forEach { m ->
arr.put(
JSONObject()
.put("ncUid", m.ncUid)
.put("ncHref", m.ncHref)
.put("ncEtag", m.ncEtag)
.put("deviceEventId", m.deviceEventId)
.put("deviceCalendarId", m.deviceCalendarId),
)
}
prefs.edit().putString(keyMappings(accountKey), arr.toString()).apply()
}
fun accountKey(serverUrl: String, username: String): String =
"${serverUrl.trimEnd('/')}|$username"
companion object {
private const val PREFS = "f7_calendar_sync"
private fun keyEnabled(account: String) = "enabled::$account"
private fun keyDeviceCal(account: String) = "device_cal::$account"
private fun keyMappings(account: String) = "mappings::$account"
private fun keyLastSync(account: String) = "last_sync::$account"
}
}
@@ -0,0 +1,645 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
import java.time.Instant
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
data class CalendarUiState(
val loading: Boolean = false,
val viewMode: CalendarViewMode = CalendarViewMode.MONTH,
val visibleMonth: YearMonth = YearMonth.now(),
val visibleYear: Int = LocalDate.now().year,
val selectedDay: LocalDate = LocalDate.now(),
val events: List<CalendarEventItem> = emptyList(),
val calendars: List<CalendarBookItem> = emptyList(),
val datePickerOpen: Boolean = false,
val createDialogOpen: Boolean = false,
val syncSettingsOpen: Boolean = false,
val settingsOpen: Boolean = false,
val trashOpen: Boolean = false,
val createBookOpen: Boolean = false,
val tasksOpen: Boolean = false,
val eventDetail: CalendarEventItem? = null,
val editorMode: CalendarEditorMode = CalendarEditorMode.CREATE,
val eventDraft: CalendarEventDraft = CalendarEventDraft(),
val createBookDraft: CalendarCreateBookDraft = CalendarCreateBookDraft(),
val trashItems: List<CalendarTrashItem> = emptyList(),
val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
val userSettings: CalendarUserSettings = CalendarUserSettings(),
val saving: Boolean = false,
val deleting: Boolean = false,
val syncing: Boolean = false,
val syncEnabled: Boolean = false,
val deviceCalendars: List<DeviceCalendarInfo> = emptyList(),
val selectedDeviceCalendarId: Long = -1L,
val lastSyncLabel: String? = null,
val needsCalendarPermission: Boolean = false,
val error: String? = null,
val snackMessage: String? = null,
val focusedEventUid: String? = null,
val unauthorized: Boolean = false,
)
class CalendarViewModel(
context: Context,
private val repository: CalendarRepository = CalendarRepository(),
) : ViewModel() {
private val appContext = context.applicationContext
private val deviceClient = DeviceCalendarClient(appContext)
private val syncStore = CalendarSyncStore(appContext)
private val prefsStore = CalendarPrefsStore(appContext)
private val syncEngine = CalendarSyncEngine(repository, deviceClient, syncStore)
private val _state = MutableStateFlow(CalendarUiState())
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
private var session: AuthSession? = null
private var visibleCalendarHrefs: Set<String> = emptySet()
private var periodicRefreshJob: Job? = null
fun bind(session: AuthSession) {
this.session = session
periodicRefreshJob?.cancel()
val accountKey = syncStore.accountKey(session.serverUrl, session.username)
visibleCalendarHrefs = prefsStore.getVisibleCalendarHrefs(accountKey)
_state.value = _state.value.copy(
viewMode = prefsStore.getViewMode(accountKey),
userSettings = prefsStore.getUserSettings(accountKey),
syncEnabled = syncStore.isEnabled(accountKey),
selectedDeviceCalendarId = syncStore.getDeviceCalendarId(accountKey),
lastSyncLabel = formatLastSync(syncStore.getLastSyncAt(accountKey)),
)
refreshDeviceCalendars()
loadCalendars(session)
if (syncStore.isEnabled(accountKey) && syncStore.getDeviceCalendarId(accountKey) > 0) {
runSyncInternal(session, syncStore.getDeviceCalendarId(accountKey), false)
} else {
reloadEvents(session)
}
startPeriodicRefresh()
}
fun refresh() {
val s = session ?: return
val calId = _state.value.selectedDeviceCalendarId
if (_state.value.syncEnabled && calId > 0) runSyncInternal(s, calId, true) else reloadEvents(s)
}
private fun refreshSilent() {
val s = session ?: return
if (!AppForegroundTracker.isForeground) return
reloadEvents(s, showLoading = false)
}
private fun startPeriodicRefresh() {
periodicRefreshJob = viewModelScope.launch {
while (isActive) {
delay(REFRESH_INTERVAL_MS)
if (AppForegroundTracker.isForeground) {
refreshSilent()
}
}
}
}
override fun onCleared() {
periodicRefreshJob?.cancel()
super.onCleared()
}
fun onCalendarPermissionResult(granted: Boolean) {
_state.value = _state.value.copy(needsCalendarPermission = !granted)
if (granted) {
refreshDeviceCalendars()
session?.let { s ->
if (_state.value.syncEnabled && _state.value.selectedDeviceCalendarId > 0) {
runSyncInternal(s, _state.value.selectedDeviceCalendarId, true)
}
}
}
}
fun refreshDeviceCalendars() {
runCatching { deviceClient.listWritableCalendars() }
.onSuccess { _state.value = _state.value.copy(deviceCalendars = it) }
}
fun setViewMode(mode: CalendarViewMode) {
val s = session ?: return
prefsStore.setViewMode(syncStore.accountKey(s.serverUrl, s.username), mode)
_state.value = _state.value.copy(viewMode = mode)
reloadEvents(s)
}
fun goToToday() {
val s = session ?: return
val today = LocalDate.now()
_state.value = _state.value.copy(selectedDay = today, visibleMonth = YearMonth.from(today), visibleYear = today.year)
reloadEvents(s)
}
fun previousPeriod() {
val s = session ?: return
val st = _state.value
_state.value = when (st.viewMode) {
CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))
CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.minusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.minusWeeks(1)))
CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.minusMonths(1), selectedDay = st.visibleMonth.minusMonths(1).atDay(1))
CalendarViewMode.YEAR -> st.copy(visibleYear = st.visibleYear - 1)
CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.minusDays(14), visibleMonth = YearMonth.from(st.selectedDay.minusDays(14)))
}
reloadEvents(s)
}
fun nextPeriod() {
val s = session ?: return
val st = _state.value
_state.value = when (st.viewMode) {
CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.plusDays(1), visibleMonth = YearMonth.from(st.selectedDay.plusDays(1)))
CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))
CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.plusMonths(1), selectedDay = st.visibleMonth.plusMonths(1).atDay(1))
CalendarViewMode.YEAR -> st.copy(visibleYear = st.visibleYear + 1)
CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.plusDays(14), visibleMonth = YearMonth.from(st.selectedDay.plusDays(14)))
}
reloadEvents(s)
}
fun openDatePicker() { _state.value = _state.value.copy(datePickerOpen = true) }
fun closeDatePicker() { _state.value = _state.value.copy(datePickerOpen = false) }
fun pickDate(day: LocalDate) {
val s = session ?: return
_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year, datePickerOpen = false)
reloadEvents(s)
}
fun selectDay(day: LocalDate) {
_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year)
if (_state.value.viewMode != CalendarViewMode.MONTH && _state.value.viewMode != CalendarViewMode.YEAR) {
session?.let { reloadEvents(it) }
}
}
fun selectYearMonth(month: YearMonth) {
val s = session ?: return
_state.value = _state.value.copy(viewMode = CalendarViewMode.MONTH, visibleMonth = month, selectedDay = month.atDay(1))
reloadEvents(s)
}
fun openTrash() {
val s = session ?: return
_state.value = _state.value.copy(trashOpen = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadTrash(s) }
.onSuccess { _state.value = _state.value.copy(trashItems = it) }
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun closeTrash() { _state.value = _state.value.copy(trashOpen = false) }
fun restoreTrash(item: CalendarTrashItem) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.restoreTrashItem(s, item) }
.onSuccess {
openTrash()
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Событие восстановлено")
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun purgeTrash(item: CalendarTrashItem) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.purgeTrashItem(s, item) }
.onSuccess { openTrash() }
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun openTasks() {
val s = session ?: return
_state.value = _state.value.copy(tasksOpen = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadUnscheduledTasks(s) }
.onSuccess { _state.value = _state.value.copy(unscheduledTasks = it) }
}
}
fun closeTasks() { _state.value = _state.value.copy(tasksOpen = false) }
fun openCreateBook() { _state.value = _state.value.copy(createBookOpen = true) }
fun closeCreateBook() { _state.value = _state.value.copy(createBookOpen = false, createBookDraft = CalendarCreateBookDraft()) }
fun updateCreateBookDraft(draft: CalendarCreateBookDraft) { _state.value = _state.value.copy(createBookDraft = draft) }
fun saveCreateBook() {
val s = session ?: return
val draft = _state.value.createBookDraft
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(saving = true)
runCatching {
when (draft.mode) {
CreateBookMode.SUBSCRIBE -> repository.subscribeCalendar(s, draft.name, draft.subscriptionUrl)
CreateBookMode.WITH_TASKS -> repository.createCalendar(s, draft.name, withTasks = true)
CreateBookMode.NEW -> repository.createCalendar(s, draft.name, withTasks = false)
}
}.onSuccess {
loadCalendars(s)
_state.value = _state.value.copy(saving = false, createBookOpen = false, snackMessage = "Календарь добавлен")
reloadEvents(s)
}.onFailure { t ->
_state.value = _state.value.copy(saving = false, error = t.message, unauthorized = t is UnauthorizedException)
}
}
}
fun importIcs(content: String) {
val s = session ?: return
val href = _state.value.calendars.firstOrNull { it.visible }?.href
?: _state.value.calendars.firstOrNull()?.href ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.importIcs(s, href, content) }
.onSuccess { count ->
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Импортировано событий: $count")
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun toggleCalendarVisibility(href: String) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
val updated = _state.value.calendars.map { if (it.href == href) it.copy(visible = !it.visible) else it }
visibleCalendarHrefs = updated.filter { it.visible }.map { it.href }.toSet()
prefsStore.setVisibleCalendarHrefs(accountKey, visibleCalendarHrefs)
_state.value = _state.value.copy(calendars = updated)
reloadEvents(s)
}
fun openSyncSettings() { refreshDeviceCalendars(); _state.value = _state.value.copy(syncSettingsOpen = true) }
fun closeSyncSettings() { _state.value = _state.value.copy(syncSettingsOpen = false) }
fun openAppSettings() { _state.value = _state.value.copy(settingsOpen = true) }
fun closeAppSettings() { _state.value = _state.value.copy(settingsOpen = false) }
fun updateUserSettings(settings: CalendarUserSettings) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
prefsStore.setUserSettings(accountKey, settings)
_state.value = _state.value.copy(userSettings = settings)
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.saveUserSetting(s, "timezone", settings.timezone)
repository.saveUserSetting(s, "showWeekends", if (settings.showWeekends) "yes" else "no")
repository.saveUserSetting(s, "showWeekNr", if (settings.showWeekNumbers) "yes" else "no")
repository.saveUserSetting(s, "defaultReminder", settings.defaultReminderMinutes.toString())
repository.saveUserSetting(s, "showTasks", if (settings.showTasks) "yes" else "no")
repository.saveUserSetting(s, "tasksSidebar", if (settings.tasksSidebar) "yes" else "no")
}
}
}
fun setSyncEnabled(enabled: Boolean) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
syncStore.setEnabled(accountKey, enabled)
_state.value = _state.value.copy(syncEnabled = enabled, needsCalendarPermission = enabled)
if (enabled && _state.value.selectedDeviceCalendarId <= 0 && _state.value.deviceCalendars.isNotEmpty()) {
selectDeviceCalendar(_state.value.deviceCalendars.first().id)
} else if (!enabled) reloadEvents(s)
}
fun selectDeviceCalendar(calendarId: Long) {
val s = session ?: return
syncStore.setDeviceCalendarId(syncStore.accountKey(s.serverUrl, s.username), calendarId)
_state.value = _state.value.copy(selectedDeviceCalendarId = calendarId)
if (_state.value.syncEnabled) runSyncInternal(s, calendarId, true)
}
fun runSyncNow() {
val s = session ?: return
val calId = _state.value.selectedDeviceCalendarId
if (calId <= 0) { _state.value = _state.value.copy(error = "Выберите календарь телефона"); return }
runSyncInternal(s, calId, true)
}
fun openCreateDialog(day: LocalDate? = null) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
val targetDay = day ?: _state.value.selectedDay
val createHref = prefsStore.getCreateCalendarHref(accountKey)
?: _state.value.calendars.firstOrNull { it.visible }?.href.orEmpty()
_state.value = _state.value.copy(
createDialogOpen = true,
editorMode = CalendarEditorMode.CREATE,
eventDetail = null,
eventDraft = CalendarEventDraft(
date = targetDay,
calendarHref = createHref,
reminderMinutes = _state.value.userSettings.defaultReminderMinutes,
),
)
}
fun closeCreateDialog() { _state.value = _state.value.copy(createDialogOpen = false) }
fun updateEventDraft(transform: (CalendarEventDraft) -> CalendarEventDraft) {
_state.value = _state.value.copy(eventDraft = transform(_state.value.eventDraft))
}
fun searchAttendees(query: String) {
val s = session ?: return
updateEventDraft { it.copy(attendeeQuery = query) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.searchAttendees(s, query) }
.onSuccess { _state.value = _state.value.copy(attendeeSuggestions = it) }
}
}
fun addAttendee(suggestion: CalendarAttendeeSuggestion) {
updateEventDraft { draft ->
if (draft.attendees.any { it.email.equals(suggestion.email, true) }) return@updateEventDraft draft
draft.copy(
attendees = draft.attendees + CalendarAttendeeItem(suggestion.email, suggestion.name),
attendeeQuery = "",
)
}
_state.value = _state.value.copy(attendeeSuggestions = emptyList())
}
fun removeAttendee(email: String) {
updateEventDraft { it.copy(attendees = it.attendees.filterNot { a -> a.email == email }) }
}
fun searchLocations(query: String) {
val s = session ?: return
updateEventDraft { it.copy(location = query) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.searchLocations(s, query) }
.onSuccess { _state.value = _state.value.copy(locationSuggestions = it) }
}
}
fun applyLocationSuggestion(suggestion: CalendarLocationSuggestion) {
updateEventDraft { it.copy(location = suggestion.address.ifBlank { suggestion.name }) }
_state.value = _state.value.copy(locationSuggestions = emptyList())
}
fun openEventDetail(event: CalendarEventItem) {
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
}
fun closeEventDetail() { _state.value = _state.value.copy(eventDetail = null, focusedEventUid = null) }
fun openEditEvent(event: CalendarEventItem) {
val zone = ZoneId.systemDefault()
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone)
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone)
val recurrence = RecurrencePreset.entries.firstOrNull { it.rrule == event.rrule } ?: RecurrencePreset.NONE
_state.value = _state.value.copy(
createDialogOpen = true,
editorMode = CalendarEditorMode.EDIT,
eventDetail = null,
eventDraft = CalendarEventDraft(
title = event.summary,
date = start.toLocalDate(),
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
allDay = event.allDay,
description = event.description,
location = event.location,
calendarHref = event.calendarHref,
recurrence = recurrence,
customRrule = if (recurrence == RecurrencePreset.NONE) event.rrule else "",
categories = event.categories.joinToString(", "),
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,
talkRoomUrl = event.talkRoomUrl,
addTalkRoom = event.talkRoomUrl.isNotBlank(),
),
focusedEventUid = event.uid,
)
}
fun duplicateEvent(event: CalendarEventItem) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.duplicateEvent(s, event, _state.value.selectedDay) }
.onSuccess {
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Событие скопировано", eventDetail = null)
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun respondToInvite(partStat: String) {
val s = session ?: return
val event = _state.value.eventDetail ?: return
val email = s.username
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.respondToInvite(s, event, partStat, email) }
.onSuccess {
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Ответ отправлен", eventDetail = null)
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun saveEventDraft() {
val s = session ?: return
val draft = _state.value.eventDraft
if (draft.title.trim().isBlank()) { _state.value = _state.value.copy(error = "Введите название"); return }
if (!draft.allDay) {
val start = parseDateTime(draft.date, draft.startTime)
val end = parseDateTime(draft.date, draft.endTime)
if (start == null || end == null) { _state.value = _state.value.copy(error = "Некорректное время"); return }
if (!end.isAfter(start)) { _state.value = _state.value.copy(error = "Окончание должно быть позже начала"); return }
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(saving = true, error = null)
val existing = findEventByUid(_state.value.focusedEventUid.orEmpty())
runCatching { repository.saveEvent(s, draft, existing) }
.onSuccess {
prefsStore.setCreateCalendarHref(syncStore.accountKey(s.serverUrl, s.username), draft.calendarHref)
_state.value = _state.value.copy(saving = false, createDialogOpen = false, snackMessage = "Сохранено")
afterMutation(s)
}
.onFailure { t ->
_state.value = _state.value.copy(saving = false, error = t.message, unauthorized = t is UnauthorizedException)
}
}
}
fun deleteSelectedEvent() {
val s = session ?: return
val event = findEventByUid(_state.value.focusedEventUid.orEmpty()) ?: _state.value.eventDetail ?: return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(deleting = true)
runCatching { repository.deleteEvent(s, event) }
.onSuccess {
_state.value = _state.value.copy(deleting = false, createDialogOpen = false, eventDetail = null, focusedEventUid = null, snackMessage = "Удалено")
afterMutation(s)
}
.onFailure { t -> _state.value = _state.value.copy(deleting = false, error = t.message) }
}
}
fun clearSnack() { _state.value = _state.value.copy(snackMessage = null) }
fun focusEventByUid(session: AuthSession, uid: String) {
viewModelScope.launch(Dispatchers.IO) {
findEventByUid(uid.trim())?.let { applyFocusedEvent(it) } ?: run {
reloadEvents(session)
findEventByUid(uid.trim())?.let { applyFocusedEvent(it) }
}
}
}
fun visibleEvents(): List<CalendarEventItem> {
val visible = _state.value.calendars.filter { it.visible }.map { it.href }.toSet()
return if (visible.isEmpty()) _state.value.events else _state.value.events.filter { it.calendarHref in visible || it.calendarHref.isBlank() }
}
fun eventsForDay(day: LocalDate) = visibleEvents().filter { CalendarRepository.eventOccursOnDay(it, day) }
fun daysWithEvents(): Set<LocalDate> = CalendarRepository.monthGridDays(_state.value.visibleMonth)
.filter { day -> visibleEvents().any { CalendarRepository.eventOccursOnDay(it, day) } }.toSet()
fun periodTitle(): String {
val st = _state.value
return when (st.viewMode) {
CalendarViewMode.YEAR -> st.visibleYear.toString()
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))}"
}
CalendarViewMode.DAY, CalendarViewMode.MONTH ->
st.selectedDay.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))
}
}
private fun loadCalendars(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.listCalendars(session) }.onSuccess { cals ->
val accountKey = syncStore.accountKey(session.serverUrl, session.username)
val visible = prefsStore.getVisibleCalendarHrefs(accountKey)
val items = cals.map { cal ->
CalendarBookItem(
href = cal.href,
displayName = cal.displayName.ifBlank { "Календарь" },
visible = visible.isEmpty() || cal.href in visible,
color = cal.color,
)
}
if (visible.isEmpty()) visibleCalendarHrefs = items.map { it.href }.toSet()
_state.value = _state.value.copy(calendars = items)
}
}
}
private fun reloadEvents(session: AuthSession, showLoading: Boolean = true) {
viewModelScope.launch(Dispatchers.IO) {
if (showLoading) {
_state.value = _state.value.copy(loading = true, error = null)
}
val st = _state.value
runCatching {
when (st.viewMode) {
CalendarViewMode.MONTH -> repository.loadMonth(session, st.visibleMonth, visibleCalendarHrefs)
CalendarViewMode.YEAR -> repository.loadRange(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)
CalendarViewMode.DAY -> repository.loadRange(session, st.selectedDay, st.selectedDay, visibleCalendarHrefs)
CalendarViewMode.WEEK -> {
val (a, b) = CalendarRepository.weekRange(st.selectedDay)
repository.loadRange(session, a, b, visibleCalendarHrefs)
}
CalendarViewMode.LIST -> {
val (a, b) = CalendarRepository.listRange(st.selectedDay)
repository.loadRange(session, a, b, visibleCalendarHrefs)
}
}
}.onSuccess { _state.value = _state.value.copy(loading = false, events = it) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message, unauthorized = it is UnauthorizedException) }
}
}
private fun afterMutation(session: AuthSession) {
val calId = _state.value.selectedDeviceCalendarId
if (_state.value.syncEnabled && calId > 0) runSyncInternal(session, calId, false) else reloadEvents(session)
}
private fun runSyncInternal(session: AuthSession, deviceCalendarId: Long, showSnack: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(syncing = true, loading = true)
runCatching { syncEngine.run(session, deviceCalendarId) }
.onSuccess { result ->
reloadEvents(session)
_state.value = _state.value.copy(
syncing = false,
snackMessage = if (showSnack) "Синхронизация завершена (${result.pushedToDevice + result.pushedToServer + result.updated + result.linked})" else _state.value.snackMessage,
lastSyncLabel = formatLastSync(syncStore.getLastSyncAt(syncStore.accountKey(session.serverUrl, session.username))),
)
}
.onFailure { t -> _state.value = _state.value.copy(syncing = false, loading = false, error = t.message) }
}
}
private fun findEventByUid(uid: String): CalendarEventItem? {
if (uid.isBlank()) return null
return visibleEvents().find { it.uid == uid || it.uid.endsWith(uid) || uid.endsWith(it.uid) }
}
private fun applyFocusedEvent(event: CalendarEventItem) {
val day = Instant.ofEpochMilli(event.startEpochMilli).atZone(ZoneId.systemDefault()).toLocalDate()
_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), eventDetail = event, focusedEventUid = event.uid)
}
private fun formatLastSync(epoch: Long) = if (epoch <= 0) null else "Синхр.: ${Instant.ofEpochMilli(epoch).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))}"
private fun parseDateTime(day: LocalDate, time: String): java.time.LocalDateTime? {
val p = time.trim().split(':')
if (p.size != 2) return null
val h = p[0].toIntOrNull() ?: return null
val m = p[1].toIntOrNull() ?: return null
if (h !in 0..23 || m !in 0..59) return null
return day.atTime(h, m)
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
CalendarViewModel(context.applicationContext) as T
}
companion object {
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
}
}
@@ -0,0 +1,207 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import ru.forbion.f7cloud.core.network.CalDavClient
import java.time.Instant
import java.time.ZoneId
data class DeviceCalendarInfo(
val id: Long,
val displayName: String,
val accountName: String,
)
data class DeviceCalendarEvent(
val eventId: Long,
val calendarId: Long,
val title: String,
val description: String,
val startEpochMilli: Long,
val endEpochMilli: Long,
val allDay: Boolean,
val lastModifiedEpochMilli: Long,
val ncUid: String?,
)
class DeviceCalendarClient(private val context: Context) {
fun listWritableCalendars(): List<DeviceCalendarInfo> {
val out = mutableListOf<DeviceCalendarInfo>()
val uri = CalendarContract.Calendars.CONTENT_URI
val projection = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.SYNC_EVENTS,
)
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND ${CalendarContract.Calendars.SYNC_EVENTS} = 1"
context.contentResolver.query(uri, projection, selection, null, null)?.use { cursor ->
val idIdx = cursor.getColumnIndex(CalendarContract.Calendars._ID)
val nameIdx = cursor.getColumnIndex(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)
val accountIdx = cursor.getColumnIndex(CalendarContract.Calendars.ACCOUNT_NAME)
while (cursor.moveToNext()) {
if (idIdx < 0 || nameIdx < 0) continue
val id = cursor.getLong(idIdx)
val name = cursor.getString(nameIdx).orEmpty().ifBlank { "Календарь" }
val account = if (accountIdx >= 0) cursor.getString(accountIdx).orEmpty() else ""
out += DeviceCalendarInfo(id, name, account)
}
}
return out.distinctBy { it.id }
}
fun queryEvents(
calendarId: Long,
rangeStartEpochMilli: Long,
rangeEndEpochMilli: Long,
): List<DeviceCalendarEvent> {
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, rangeStartEpochMilli)
ContentUris.appendId(builder, rangeEndEpochMilli)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.DESCRIPTION,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.LAST_DATE,
)
val selection = "${CalendarContract.Instances.CALENDAR_ID} = ?"
val args = arrayOf(calendarId.toString())
val out = mutableListOf<DeviceCalendarEvent>()
context.contentResolver.query(uri, projection, selection, args, null)?.use { cursor ->
val eventIdIdx = cursor.getColumnIndex(CalendarContract.Instances.EVENT_ID)
val calIdx = cursor.getColumnIndex(CalendarContract.Instances.CALENDAR_ID)
val titleIdx = cursor.getColumnIndex(CalendarContract.Instances.TITLE)
val descIdx = cursor.getColumnIndex(CalendarContract.Instances.DESCRIPTION)
val beginIdx = cursor.getColumnIndex(CalendarContract.Instances.BEGIN)
val endIdx = cursor.getColumnIndex(CalendarContract.Instances.END)
val allDayIdx = cursor.getColumnIndex(CalendarContract.Instances.ALL_DAY)
val lastIdx = cursor.getColumnIndex(CalendarContract.Instances.LAST_DATE)
while (cursor.moveToNext()) {
if (eventIdIdx < 0 || beginIdx < 0 || endIdx < 0) continue
val description = if (descIdx >= 0) cursor.getString(descIdx).orEmpty() else ""
out += DeviceCalendarEvent(
eventId = cursor.getLong(eventIdIdx),
calendarId = if (calIdx >= 0) cursor.getLong(calIdx) else calendarId,
title = if (titleIdx >= 0) cursor.getString(titleIdx).orEmpty() else "",
description = description,
startEpochMilli = cursor.getLong(beginIdx),
endEpochMilli = cursor.getLong(endIdx),
allDay = allDayIdx >= 0 && cursor.getInt(allDayIdx) == 1,
lastModifiedEpochMilli = if (lastIdx >= 0) cursor.getLong(lastIdx) else cursor.getLong(beginIdx),
ncUid = parseNcUid(description),
)
}
}
return out.distinctBy { it.eventId }
}
fun insertEvent(
calendarId: Long,
title: String,
description: String,
startEpochMilli: Long,
endEpochMilli: Long,
allDay: Boolean,
ncUid: String?,
): Long {
val zone = ZoneId.systemDefault()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, title)
put(CalendarContract.Events.DTSTART, startEpochMilli)
put(CalendarContract.Events.DTEND, endEpochMilli)
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
put(CalendarContract.Events.EVENT_END_TIMEZONE, zone.id)
put(CalendarContract.Events.ALL_DAY, if (allDay) 1 else 0)
put(CalendarContract.Events.DESCRIPTION, buildDescription(description, ncUid))
put(CalendarContract.Events.AVAILABILITY, CalendarContract.Events.AVAILABILITY_BUSY)
}
val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: error("Не удалось создать событие в календаре телефона")
return ContentUris.parseId(uri)
}
fun updateEvent(
eventId: Long,
title: String,
description: String,
startEpochMilli: Long,
endEpochMilli: Long,
allDay: Boolean,
ncUid: String?,
) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
val values = ContentValues().apply {
put(CalendarContract.Events.TITLE, title)
put(CalendarContract.Events.DTSTART, startEpochMilli)
put(CalendarContract.Events.DTEND, endEpochMilli)
put(CalendarContract.Events.ALL_DAY, if (allDay) 1 else 0)
put(CalendarContract.Events.DESCRIPTION, buildDescription(description, ncUid))
}
context.contentResolver.update(uri, values, null, null)
}
fun deleteEvent(eventId: Long) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
context.contentResolver.delete(uri, null, null)
}
fun stampNcUid(eventId: Long, ncUid: String) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
val existing = readDescription(eventId)
val values = ContentValues().apply {
put(CalendarContract.Events.DESCRIPTION, buildDescription(existing, ncUid))
}
context.contentResolver.update(uri, values, null, null)
}
private fun readDescription(eventId: Long): String {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
context.contentResolver.query(
uri,
arrayOf(CalendarContract.Events.DESCRIPTION),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(CalendarContract.Events.DESCRIPTION)
if (idx >= 0) return cursor.getString(idx).orEmpty()
}
}
return ""
}
companion object {
fun parseNcUid(description: String?): String? {
val text = description.orEmpty()
val prefix = CalDavClient.F7CLOUD_UID_MARKER_PREFIX
val line = text.lines().firstOrNull { it.startsWith(prefix, ignoreCase = true) }
?: return null
return line.removePrefix(prefix).trim().takeIf { it.isNotBlank() }
}
fun buildDescription(existing: String, ncUid: String?): String {
val lines = existing.lines().filter {
!it.startsWith(CalDavClient.F7CLOUD_UID_MARKER_PREFIX, ignoreCase = true)
}
val base = lines.joinToString("\n").trim()
return if (ncUid.isNullOrBlank()) {
base
} else {
listOf(base, "${CalDavClient.F7CLOUD_UID_MARKER_PREFIX}$ncUid")
.filter { it.isNotBlank() }
.joinToString("\n")
}
}
}
}