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:
@@ -0,0 +1,41 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'ru.forbion.f7cloud.feature.calendar'
|
||||
compileSdk 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk 26
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:designsystem')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation 'androidx.activity:activity-compose:1.10.1'
|
||||
implementation 'androidx.core:core-ktx:1.15.0'
|
||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.7.0'
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
+163
@@ -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,
|
||||
)
|
||||
}
|
||||
+1362
File diff suppressed because it is too large
Load Diff
+290
@@ -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,
|
||||
}
|
||||
+63
@@ -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"
|
||||
}
|
||||
}
|
||||
+446
@@ -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
|
||||
}
|
||||
}
|
||||
+212
@@ -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
|
||||
}
|
||||
}
|
||||
+86
@@ -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"
|
||||
}
|
||||
}
|
||||
+645
@@ -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"))
|
||||
}
|
||||
}
|
||||
+207
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user