Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1f15e33aa | ||
|
|
3b649ec310 |
@@ -11,6 +11,7 @@ import com.scarriffle.calendarr.data.remote.jsonBody
|
|||||||
import com.scarriffle.calendarr.domain.model.AppSettings
|
import com.scarriffle.calendarr.domain.model.AppSettings
|
||||||
import com.scarriffle.calendarr.domain.model.CalDAVAccount
|
import com.scarriffle.calendarr.domain.model.CalDAVAccount
|
||||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||||
|
import com.scarriffle.calendarr.domain.model.EventAttachment
|
||||||
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
|
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
|
||||||
import com.scarriffle.calendarr.domain.model.DirectoryUser
|
import com.scarriffle.calendarr.domain.model.DirectoryUser
|
||||||
import com.scarriffle.calendarr.domain.model.Group
|
import com.scarriffle.calendarr.domain.model.Group
|
||||||
@@ -27,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -437,9 +439,13 @@ class CalendarRepository @Inject constructor(
|
|||||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||||
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
isPrivate: Boolean = false, reminders: List<Int>? = null,
|
||||||
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
|
||||||
) = guarded {
|
): String? = guarded {
|
||||||
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
|
val resp = api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
|
||||||
.ensureSuccess()
|
resp.ensureSuccess()
|
||||||
|
// The response is the full event dict; its "id" is the uid attachments
|
||||||
|
// are addressed by. Callers that don't need it simply ignore it.
|
||||||
|
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("id").ifBlank { null } }
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateLocalEvent(
|
suspend fun updateLocalEvent(
|
||||||
@@ -504,6 +510,50 @@ class CalendarRepository @Inject constructor(
|
|||||||
return importIcs(calendarId, part)
|
return importIcs(calendarId, part)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Attachments ----
|
||||||
|
|
||||||
|
suspend fun listAttachments(uid: String): List<EventAttachment> = guarded {
|
||||||
|
val resp = api.listAttachments(uid)
|
||||||
|
resp.ensureSuccess()
|
||||||
|
val arr = JSONArray(resp.body()?.string() ?: "[]")
|
||||||
|
buildList {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
arr.optJSONObject(i)?.let { EventAttachment.fromJson(it)?.let(::add) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload one file. The server sniffs the bytes, so the media type here is
|
||||||
|
* only about forming a valid multipart part. */
|
||||||
|
suspend fun uploadAttachment(uid: String, bytes: ByteArray, filename: String,
|
||||||
|
mimeType: String?): EventAttachment = guarded {
|
||||||
|
val body = bytes.toRequestBody((mimeType ?: "application/octet-stream").toMediaTypeOrNull())
|
||||||
|
val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body)
|
||||||
|
val resp = api.uploadAttachment(uid, part)
|
||||||
|
resp.ensureSuccess()
|
||||||
|
EventAttachment.fromJson(JSONObject(resp.body()?.string() ?: "{}"))
|
||||||
|
?: error("Unerwartete Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadAttachment(id: Int): ByteArray = guarded {
|
||||||
|
val resp = api.downloadAttachment(id)
|
||||||
|
resp.ensureSuccess()
|
||||||
|
resp.body()?.bytes() ?: ByteArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thumbnail bytes, or null when there is none — a missing preview just
|
||||||
|
* means the row shows its type icon. */
|
||||||
|
suspend fun attachmentThumbnail(id: Int): ByteArray? = try {
|
||||||
|
val resp = api.attachmentThumbnail(id)
|
||||||
|
if (resp.isSuccessful) resp.body()?.bytes() else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteAttachment(id: Int): Unit = guarded {
|
||||||
|
api.deleteAttachment(id).ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun exportIcs(calendarId: Int): ByteArray = guarded {
|
suspend fun exportIcs(calendarId: Int): ByteArray = guarded {
|
||||||
val resp = api.exportCalendar(calendarId)
|
val resp = api.exportCalendar(calendarId)
|
||||||
resp.ensureSuccess()
|
resp.ensureSuccess()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import retrofit2.http.HTTP
|
|||||||
import retrofit2.http.Multipart
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.PATCH
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Streaming
|
||||||
import retrofit2.http.PUT
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Part
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
@@ -86,6 +87,28 @@ interface CalendarrApi {
|
|||||||
@GET("api/local/calendars/{id}/export")
|
@GET("api/local/calendars/{id}/export")
|
||||||
suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody>
|
suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody>
|
||||||
|
|
||||||
|
// ---- Attachments ----
|
||||||
|
// Like the event endpoints, these return the raw body and are parsed by
|
||||||
|
// hand, mirroring the iOS client.
|
||||||
|
|
||||||
|
@GET("api/local/events/{uid}/attachments")
|
||||||
|
suspend fun listAttachments(@Path("uid") uid: String): Response<ResponseBody>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("api/local/events/{uid}/attachments")
|
||||||
|
suspend fun uploadAttachment(@Path("uid") uid: String,
|
||||||
|
@Part part: MultipartBody.Part): Response<ResponseBody>
|
||||||
|
|
||||||
|
@Streaming
|
||||||
|
@GET("api/local/attachments/{id}")
|
||||||
|
suspend fun downloadAttachment(@Path("id") id: Int): Response<ResponseBody>
|
||||||
|
|
||||||
|
@GET("api/local/attachments/{id}/thumb")
|
||||||
|
suspend fun attachmentThumbnail(@Path("id") id: Int): Response<ResponseBody>
|
||||||
|
|
||||||
|
@DELETE("api/local/attachments/{id}")
|
||||||
|
suspend fun deleteAttachment(@Path("id") id: Int): Response<ResponseBody>
|
||||||
|
|
||||||
// ---- Groups ----
|
// ---- Groups ----
|
||||||
|
|
||||||
@GET("api/groups/")
|
@GET("api/groups/")
|
||||||
|
|||||||
@@ -11,6 +11,77 @@ import java.time.Instant
|
|||||||
/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */
|
/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */
|
||||||
data class EventPerson(val id: Int?, val displayName: String)
|
data class EventPerson(val id: Int?, val displayName: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a string field, treating JSON null correctly. Android's
|
||||||
|
* [JSONObject.optString] returns the literal string "null" for a JSON null
|
||||||
|
* value, which previously made every event blue (color "null" → unparseable
|
||||||
|
* → fallback) and showed "null" for empty location/notes.
|
||||||
|
*
|
||||||
|
* File-scope rather than inside CalEvent's companion, so EventAttachment can
|
||||||
|
* use it as well.
|
||||||
|
*/
|
||||||
|
private fun JSONObject.strOrNull(key: String): String? {
|
||||||
|
if (!has(key) || isNull(key)) return null
|
||||||
|
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file attached to an event. Mirrors iOS `EventAttachment`.
|
||||||
|
*/
|
||||||
|
data class EventAttachment(
|
||||||
|
val id: Int,
|
||||||
|
val filename: String,
|
||||||
|
val contentType: String,
|
||||||
|
val sizeBytes: Int,
|
||||||
|
val hasThumbnail: Boolean,
|
||||||
|
val uploadedBy: EventPerson? = null,
|
||||||
|
) {
|
||||||
|
val isImage: Boolean get() = contentType.startsWith("image/")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(json: JSONObject): EventAttachment? {
|
||||||
|
val id = (json.opt("id") as? Number)?.toInt() ?: return null
|
||||||
|
val filename = json.strOrNull("filename") ?: return null
|
||||||
|
return EventAttachment(
|
||||||
|
id = id,
|
||||||
|
filename = filename,
|
||||||
|
contentType = json.strOrNull("content_type") ?: "application/octet-stream",
|
||||||
|
sizeBytes = json.optInt("size_bytes", 0),
|
||||||
|
hasThumbnail = json.optBoolean("has_thumb", false),
|
||||||
|
uploadedBy = json.optJSONObject("uploaded_by")?.let { obj ->
|
||||||
|
obj.strOrNull("display_name")?.let { name ->
|
||||||
|
EventPerson((obj.opt("id") as? Number)?.toInt(), name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file picked in the editor but not uploaded yet. The bytes are read at pick
|
||||||
|
* time (the content URI may not survive the save), and the event it belongs to
|
||||||
|
* may not exist yet.
|
||||||
|
*/
|
||||||
|
data class StagedAttachment(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val filename: String,
|
||||||
|
val mimeType: String?,
|
||||||
|
) {
|
||||||
|
// ByteArray uses identity equality, so a data class holding one needs these
|
||||||
|
// spelled out or two distinct picks of the same file compare unequal in a
|
||||||
|
// list diff.
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is StagedAttachment) return false
|
||||||
|
return filename == other.filename && mimeType == other.mimeType &&
|
||||||
|
bytes.contentEquals(other.bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int =
|
||||||
|
31 * (31 * filename.hashCode() + (mimeType?.hashCode() ?: 0)) + bytes.contentHashCode()
|
||||||
|
}
|
||||||
|
|
||||||
data class CalEvent(
|
data class CalEvent(
|
||||||
val id: String,
|
val id: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
@@ -41,6 +112,10 @@ data class CalEvent(
|
|||||||
// True for events from a birthday calendar — clients show a cake icon and
|
// True for events from a birthday calendar — clients show a cake icon and
|
||||||
// the server bakes the age into `displayTitle`.
|
// the server bakes the age into `displayTitle`.
|
||||||
val isBirthday: Boolean = false,
|
val isBirthday: Boolean = false,
|
||||||
|
// How many files hang off this event. The list payload carries only the
|
||||||
|
// count; the files themselves are fetched when the detail screen opens.
|
||||||
|
// Absent on non-local sources and on busy-masked private events, so 0.
|
||||||
|
val attachmentCount: Int = 0,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Title to render: the server-decorated one (birthday age, group prefix)
|
* Title to render: the server-decorated one (birthday age, group prefix)
|
||||||
@@ -70,17 +145,6 @@ data class CalEvent(
|
|||||||
return FALLBACK_PALETTE[idx]
|
return FALLBACK_PALETTE[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a string field, treating JSON null correctly. Android's
|
|
||||||
* [JSONObject.optString] returns the literal string "null" for a JSON
|
|
||||||
* null value, which previously made every event blue (color "null" →
|
|
||||||
* unparseable → fallback) and showed "null" for empty location/notes.
|
|
||||||
*/
|
|
||||||
private fun JSONObject.strOrNull(key: String): String? {
|
|
||||||
if (!has(key) || isNull(key)) return null
|
|
||||||
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse a {id, display_name} person object (creator/owner). */
|
/** Parse a {id, display_name} person object (creator/owner). */
|
||||||
private fun personFrom(json: JSONObject?, key: String): EventPerson? {
|
private fun personFrom(json: JSONObject?, key: String): EventPerson? {
|
||||||
val obj = json?.optJSONObject(key) ?: return null
|
val obj = json?.optJSONObject(key) ?: return null
|
||||||
@@ -139,6 +203,7 @@ data class CalEvent(
|
|||||||
} ?: emptyList(),
|
} ?: emptyList(),
|
||||||
readOnly = json.optBoolean("read_only", false),
|
readOnly = json.optBoolean("read_only", false),
|
||||||
isBirthday = json.optBoolean("is_birthday", false),
|
isBirthday = json.optBoolean("is_birthday", false),
|
||||||
|
attachmentCount = json.optInt("attachment_count", 0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ object L10n {
|
|||||||
"event.save" to "Sichern", "event.add" to "Hinzufügen",
|
"event.save" to "Sichern", "event.add" to "Hinzufügen",
|
||||||
"event.delete_confirm" to "Diesen Termin löschen?",
|
"event.delete_confirm" to "Diesen Termin löschen?",
|
||||||
"event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen",
|
"event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen",
|
||||||
|
"event.attachments" to "Anhänge", "event.attachment_add" to "Datei hinzufügen",
|
||||||
|
"event.attachment_hint" to "Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.",
|
||||||
"event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit",
|
"event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit",
|
||||||
"event.reminder_before" to "vorher",
|
"event.reminder_before" to "vorher",
|
||||||
"event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden",
|
"event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden",
|
||||||
@@ -318,6 +320,8 @@ object L10n {
|
|||||||
"event.save" to "Save", "event.add" to "Add",
|
"event.save" to "Save", "event.add" to "Add",
|
||||||
"event.delete_confirm" to "Delete this event?",
|
"event.delete_confirm" to "Delete this event?",
|
||||||
"event.reminders" to "Reminders", "event.reminder_add" to "Add reminder",
|
"event.reminders" to "Reminders", "event.reminder_add" to "Add reminder",
|
||||||
|
"event.attachments" to "Attachments", "event.attachment_add" to "Add file",
|
||||||
|
"event.attachment_hint" to "Max. 10 MB per file, 10 files. PDF, images, text.",
|
||||||
"event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time",
|
"event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time",
|
||||||
"event.reminder_before" to "before",
|
"event.reminder_before" to "before",
|
||||||
"event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours",
|
"event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -156,6 +157,19 @@ fun CalendarScreen(
|
|||||||
|
|
||||||
var viewMenuOpen by remember { mutableStateOf(false) }
|
var viewMenuOpen by remember { mutableStateOf(false) }
|
||||||
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
|
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
|
||||||
|
// Downloading an attachment is two steps, like the .ics export: fetch the
|
||||||
|
// bytes, then let the user choose where to put them. Going through SAF
|
||||||
|
// means no FileProvider entry and no storage permission.
|
||||||
|
var pendingAttachment by remember { mutableStateOf<ByteArray?>(null) }
|
||||||
|
val saveAttachment = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||||
|
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("*/*")
|
||||||
|
) { uri ->
|
||||||
|
val bytes = pendingAttachment
|
||||||
|
pendingAttachment = null
|
||||||
|
if (uri != null && bytes != null) {
|
||||||
|
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
||||||
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
||||||
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
|
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
|
||||||
@@ -328,9 +342,23 @@ fun CalendarScreen(
|
|||||||
) {
|
) {
|
||||||
val ev = lastDetail
|
val ev = lastDetail
|
||||||
if (ev != null) {
|
if (ev != null) {
|
||||||
|
val attachments by vm.attachments.collectAsState()
|
||||||
|
val attachmentThumbs by vm.attachmentThumbs.collectAsState()
|
||||||
|
LaunchedEffect(ev.id) { vm.loadAttachments(ev) }
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
event = ev,
|
event = ev,
|
||||||
currentUserId = vm.currentUserId,
|
currentUserId = vm.currentUserId,
|
||||||
|
attachments = attachments,
|
||||||
|
attachmentThumbs = attachmentThumbs,
|
||||||
|
onOpenAttachment = { att ->
|
||||||
|
// Fetch the bytes, then let the user pick where to save
|
||||||
|
// them — the same two-step the .ics export already uses,
|
||||||
|
// which needs no FileProvider entry in the manifest.
|
||||||
|
vm.downloadAttachment(att) { bytes ->
|
||||||
|
pendingAttachment = bytes
|
||||||
|
saveAttachment.launch(att.filename)
|
||||||
|
}
|
||||||
|
},
|
||||||
onClose = { detailEvent = null },
|
onClose = { detailEvent = null },
|
||||||
onEdit = {
|
onEdit = {
|
||||||
detailEvent = null
|
detailEvent = null
|
||||||
@@ -349,14 +377,19 @@ fun CalendarScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
editor?.let { req ->
|
editor?.let { req ->
|
||||||
|
// The editor needs the event's current attachments to offer removing
|
||||||
|
// them; loading is the same call the detail screen makes.
|
||||||
|
LaunchedEffect(req.existing?.id) { req.existing?.let { vm.loadAttachments(it) } }
|
||||||
|
val editorAttachments by vm.attachments.collectAsState()
|
||||||
EventEditorSheet(
|
EventEditorSheet(
|
||||||
request = req,
|
request = req,
|
||||||
writableCalendars = state.writableCalendars,
|
writableCalendars = state.writableCalendars,
|
||||||
|
existingAttachments = if (req.existing?.source == "local") editorAttachments else emptyList(),
|
||||||
onDismiss = { editor = null },
|
onDismiss = { editor = null },
|
||||||
defaultDurationMinutes = vm.defaultEventDurationMinutes,
|
defaultDurationMinutes = vm.defaultEventDurationMinutes,
|
||||||
reminderDisabledKeys = state.reminderDisabledKeys,
|
reminderDisabledKeys = state.reminderDisabledKeys,
|
||||||
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders ->
|
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed ->
|
||||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error ->
|
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed) { error ->
|
||||||
if (error == null) editor = null
|
if (error == null) editor = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+74
-4
@@ -1,5 +1,8 @@
|
|||||||
package com.scarriffle.calendarr.ui.calendar
|
package com.scarriffle.calendarr.ui.calendar
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.scarriffle.calendarr.data.CalendarRepository
|
import com.scarriffle.calendarr.data.CalendarRepository
|
||||||
@@ -7,9 +10,11 @@ import com.scarriffle.calendarr.data.SettingsStore
|
|||||||
import com.scarriffle.calendarr.data.SyncError
|
import com.scarriffle.calendarr.data.SyncError
|
||||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
import com.scarriffle.calendarr.domain.model.CalViewType
|
||||||
|
import com.scarriffle.calendarr.domain.model.EventAttachment
|
||||||
import com.scarriffle.calendarr.domain.model.Group
|
import com.scarriffle.calendarr.domain.model.Group
|
||||||
import com.scarriffle.calendarr.domain.model.GroupMember
|
import com.scarriffle.calendarr.domain.model.GroupMember
|
||||||
import com.scarriffle.calendarr.domain.model.LocalCalendar
|
import com.scarriffle.calendarr.domain.model.LocalCalendar
|
||||||
|
import com.scarriffle.calendarr.domain.model.StagedAttachment
|
||||||
import com.scarriffle.calendarr.domain.model.WritableCalendar
|
import com.scarriffle.calendarr.domain.model.WritableCalendar
|
||||||
import com.scarriffle.calendarr.util.Dates
|
import com.scarriffle.calendarr.util.Dates
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -17,9 +22,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -731,20 +738,25 @@ class CalendarViewModel @Inject constructor(
|
|||||||
color: String?,
|
color: String?,
|
||||||
isPrivate: Boolean,
|
isPrivate: Boolean,
|
||||||
reminders: List<Int> = emptyList(),
|
reminders: List<Int> = emptyList(),
|
||||||
|
stagedAttachments: List<StagedAttachment> = emptyList(),
|
||||||
|
removedAttachmentIds: Set<Int> = emptySet(),
|
||||||
onResult: (String?) -> Unit,
|
onResult: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
if (existing != null && existing.source == calendar.source) {
|
if (existing != null && existing.source == calendar.source) {
|
||||||
when (existing.source) {
|
when (existing.source) {
|
||||||
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
"local" -> {
|
||||||
|
repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
||||||
|
applyAttachmentChanges(existing.id, stagedAttachments, removedAttachmentIds)
|
||||||
|
}
|
||||||
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
|
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||||
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
||||||
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
|
||||||
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders, stagedAttachments)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders, stagedAttachments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.onSuccess { afterMutation(); onResult(null) }
|
result.onSuccess { afterMutation(); onResult(null) }
|
||||||
@@ -756,15 +768,73 @@ class CalendarViewModel @Inject constructor(
|
|||||||
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
||||||
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
|
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
|
||||||
reminders: List<Int> = emptyList(),
|
reminders: List<Int> = emptyList(),
|
||||||
|
stagedAttachments: List<StagedAttachment> = emptyList(),
|
||||||
) {
|
) {
|
||||||
when (calendar.source) {
|
when (calendar.source) {
|
||||||
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
"local" -> {
|
||||||
|
// Only now does a uid exist to attach the staged files to.
|
||||||
|
val uid = repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
|
||||||
|
if (uid != null) applyAttachmentChanges(uid, stagedAttachments, emptySet())
|
||||||
|
}
|
||||||
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||||
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||||
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Attachments ----
|
||||||
|
// The event payload carries only a count, so the list is fetched when the
|
||||||
|
// detail screen opens. Thumbnails are small server-rendered JPEGs, decoded
|
||||||
|
// here rather than pulling in an image-loading dependency for them.
|
||||||
|
|
||||||
|
private val _attachments = MutableStateFlow<List<EventAttachment>>(emptyList())
|
||||||
|
val attachments: StateFlow<List<EventAttachment>> = _attachments.asStateFlow()
|
||||||
|
|
||||||
|
private val _attachmentThumbs = MutableStateFlow<Map<Int, ImageBitmap>>(emptyMap())
|
||||||
|
val attachmentThumbs: StateFlow<Map<Int, ImageBitmap>> = _attachmentThumbs.asStateFlow()
|
||||||
|
|
||||||
|
fun loadAttachments(event: CalEvent) {
|
||||||
|
clearAttachments()
|
||||||
|
if (event.source != "local" || event.attachmentCount <= 0) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val loaded = runCatching { repository.listAttachments(event.id) }.getOrDefault(emptyList())
|
||||||
|
_attachments.value = loaded
|
||||||
|
val thumbs = mutableMapOf<Int, ImageBitmap>()
|
||||||
|
for (att in loaded.filter { it.hasThumbnail }) {
|
||||||
|
val bytes = repository.attachmentThumbnail(att.id) ?: continue
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
} ?: continue
|
||||||
|
thumbs[att.id] = bitmap.asImageBitmap()
|
||||||
|
}
|
||||||
|
_attachmentThumbs.value = thumbs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applied after the event itself was saved; a failure leaves the event. */
|
||||||
|
private suspend fun applyAttachmentChanges(
|
||||||
|
uid: String,
|
||||||
|
staged: List<StagedAttachment>,
|
||||||
|
removedIds: Set<Int>,
|
||||||
|
) {
|
||||||
|
for (id in removedIds) runCatching { repository.deleteAttachment(id) }
|
||||||
|
for (file in staged) {
|
||||||
|
runCatching { repository.uploadAttachment(uid, file.bytes, file.filename, file.mimeType) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAttachments() {
|
||||||
|
_attachments.value = emptyList()
|
||||||
|
_attachmentThumbs.value = emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download the bytes and hand them to the caller (which writes them via SAF). */
|
||||||
|
fun downloadAttachment(att: EventAttachment, onReady: (ByteArray) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.downloadAttachment(att.id) }.onSuccess(onReady)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) {
|
fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.scarriffle.calendarr.ui.event
|
package com.scarriffle.calendarr.ui.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -14,8 +16,13 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
@@ -51,6 +58,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||||
|
import com.scarriffle.calendarr.domain.model.EventAttachment
|
||||||
import com.scarriffle.calendarr.ui.LocalLang
|
import com.scarriffle.calendarr.ui.LocalLang
|
||||||
import com.scarriffle.calendarr.ui.calendar.eventDateRange
|
import com.scarriffle.calendarr.ui.calendar.eventDateRange
|
||||||
import com.scarriffle.calendarr.ui.tr
|
import com.scarriffle.calendarr.ui.tr
|
||||||
@@ -66,6 +74,9 @@ fun EventDetailScreen(
|
|||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onCopy: () -> Unit,
|
onCopy: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
attachments: List<EventAttachment> = emptyList(),
|
||||||
|
attachmentThumbs: Map<Int, ImageBitmap> = emptyMap(),
|
||||||
|
onOpenAttachment: (EventAttachment) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val lang = LocalLang.current
|
val lang = LocalLang.current
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
@@ -118,6 +129,59 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
|
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
|
||||||
|
|
||||||
|
// Attachments exist on local events only; the payload carries
|
||||||
|
// just the count, so the list arrives once the screen is open.
|
||||||
|
if (event.source == "local" && event.attachmentCount > 0) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
tr("event.attachments"),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
attachments.forEach { att ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenAttachment(att) }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
val thumb = attachmentThumbs[att.id]
|
||||||
|
if (thumb != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = thumb,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.AttachFile,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
att.filename,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
formatAttachmentSize(att.sizeBytes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
|
||||||
Icon(Icons.Filled.ContentCopy, contentDescription = null)
|
Icon(Icons.Filled.ContentCopy, contentDescription = null)
|
||||||
@@ -166,3 +230,10 @@ private fun DetailRow(icon: ImageVector, text: String) {
|
|||||||
Text(text, style = MaterialTheme.typography.bodyLarge)
|
Text(text, style = MaterialTheme.typography.bodyLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable size, matching what the web and iOS show. */
|
||||||
|
private fun formatAttachmentSize(bytes: Int): String = when {
|
||||||
|
bytes < 1024 -> "$bytes B"
|
||||||
|
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||||
|
else -> String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -44,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -52,7 +56,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||||
|
import com.scarriffle.calendarr.domain.model.EventAttachment
|
||||||
import com.scarriffle.calendarr.domain.model.ReminderOptions
|
import com.scarriffle.calendarr.domain.model.ReminderOptions
|
||||||
|
import com.scarriffle.calendarr.domain.model.StagedAttachment
|
||||||
import com.scarriffle.calendarr.domain.model.WritableCalendar
|
import com.scarriffle.calendarr.domain.model.WritableCalendar
|
||||||
import com.scarriffle.calendarr.ui.L10n
|
import com.scarriffle.calendarr.ui.L10n
|
||||||
import com.scarriffle.calendarr.ui.LocalLang
|
import com.scarriffle.calendarr.ui.LocalLang
|
||||||
@@ -79,7 +85,8 @@ fun EventEditorSheet(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
defaultDurationMinutes: Int = 60,
|
defaultDurationMinutes: Int = 60,
|
||||||
reminderDisabledKeys: Set<String> = emptySet(),
|
reminderDisabledKeys: Set<String> = emptySet(),
|
||||||
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>) -> Unit,
|
existingAttachments: List<EventAttachment> = emptyList(),
|
||||||
|
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>, List<StagedAttachment>, Set<Int>) -> Unit,
|
||||||
) {
|
) {
|
||||||
val zone = ZoneId.systemDefault()
|
val zone = ZoneId.systemDefault()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -126,6 +133,28 @@ fun EventEditorSheet(
|
|||||||
}
|
}
|
||||||
var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) }
|
var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) }
|
||||||
var calMenuOpen by remember { mutableStateOf(false) }
|
var calMenuOpen by remember { mutableStateOf(false) }
|
||||||
|
// Files picked before the event exists are staged and uploaded after it is
|
||||||
|
// created; removals of existing ones are applied on save, so Cancel really
|
||||||
|
// does cancel.
|
||||||
|
var stagedAttachments by remember { mutableStateOf(emptyList<StagedAttachment>()) }
|
||||||
|
var removedAttachmentIds by remember { mutableStateOf(emptySet<Int>()) }
|
||||||
|
val attachmentSlotsUsed =
|
||||||
|
existingAttachments.count { it.id !in removedAttachmentIds } + stagedAttachments.size
|
||||||
|
val pickAttachment = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null && attachmentSlotsUsed < 10) {
|
||||||
|
// Read the bytes now: the content URI may not outlive this screen.
|
||||||
|
val bytes = runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}.getOrNull()
|
||||||
|
val name = queryDisplayName(context, uri) ?: "anhang"
|
||||||
|
val mime = context.contentResolver.getType(uri)
|
||||||
|
if (bytes != null && bytes.size <= 10 * 1024 * 1024) {
|
||||||
|
stagedAttachments = stagedAttachments + StagedAttachment(bytes, name, mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy")
|
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy")
|
||||||
@@ -282,6 +311,42 @@ fun EventEditorSheet(
|
|||||||
},
|
},
|
||||||
) { Text(tr("event.reminder_add")) }
|
) { Text(tr("event.reminder_add")) }
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
|
|
||||||
|
Text(tr("event.attachments"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
existingAttachments.filter { it.id !in removedAttachmentIds }.forEach { att ->
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(att.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||||
|
androidx.compose.material3.IconButton(onClick = {
|
||||||
|
removedAttachmentIds = removedAttachmentIds + att.id
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stagedAttachments.forEach { file ->
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(file.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||||
|
androidx.compose.material3.IconButton(onClick = {
|
||||||
|
stagedAttachments = stagedAttachments - file
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
enabled = attachmentSlotsUsed < 10,
|
||||||
|
onClick = { pickAttachment.launch(ATTACHMENT_MIME_TYPES) },
|
||||||
|
) { Text(tr("event.attachment_add")) }
|
||||||
|
Text(
|
||||||
|
tr("event.attachment_hint"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color
|
// Color
|
||||||
@@ -325,7 +390,9 @@ fun EventEditorSheet(
|
|||||||
end = endDate.atTime(endTime).atZone(zone).toInstant()
|
end = endDate.atTime(endTime).atZone(zone).toInstant()
|
||||||
}
|
}
|
||||||
val rem = if (cal.source == "local") reminders else emptyList()
|
val rem = if (cal.source == "local") reminders else emptyList()
|
||||||
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem)
|
val staged = if (cal.source == "local") stagedAttachments else emptyList()
|
||||||
|
val removed = if (cal.source == "local") removedAttachmentIds else emptySet()
|
||||||
|
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem, staged, removed)
|
||||||
},
|
},
|
||||||
enabled = writableCalendars.isNotEmpty(),
|
enabled = writableCalendars.isNotEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -405,3 +472,21 @@ private fun ReminderRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mirrors the server allowlist. "*/*" is deliberately absent — the picker
|
||||||
|
* should not offer files the upload would reject. */
|
||||||
|
private val ATTACHMENT_MIME_TYPES = arrayOf(
|
||||||
|
"application/pdf",
|
||||||
|
"image/png", "image/jpeg", "image/webp", "image/gif",
|
||||||
|
"text/plain", "text/markdown", "text/csv", "text/comma-separated-values",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The user-visible name behind a content URI; the last path segment is an
|
||||||
|
* opaque id and would end up as the attachment's filename. */
|
||||||
|
private fun queryDisplayName(context: android.content.Context, uri: android.net.Uri): String? =
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|||||||
@@ -18,6 +18,48 @@ struct EventPerson: Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file attached to an event.
|
||||||
|
///
|
||||||
|
/// Deliberately declared here rather than in a file of its own: a new .swift
|
||||||
|
/// file added outside Xcode is not a member of any target and simply would not
|
||||||
|
/// compile in, which is invisible until someone opens the project on a Mac.
|
||||||
|
struct EventAttachment: Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let filename: String
|
||||||
|
let contentType: String
|
||||||
|
let sizeBytes: Int
|
||||||
|
let hasThumbnail: Bool
|
||||||
|
let uploadedBy: EventPerson?
|
||||||
|
|
||||||
|
var isImage: Bool { contentType.hasPrefix("image/") }
|
||||||
|
|
||||||
|
/// SF Symbol for the file kind — no network, unlike the thumbnail.
|
||||||
|
var symbolName: String {
|
||||||
|
if contentType == "application/pdf" { return "doc.richtext" }
|
||||||
|
if isImage { return "photo" }
|
||||||
|
return "doc.text"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displaySize: String {
|
||||||
|
ByteCountFormatter.string(fromByteCount: Int64(sizeBytes), countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(json: [String: Any]) -> EventAttachment? {
|
||||||
|
guard
|
||||||
|
let id = json["id"] as? Int,
|
||||||
|
let filename = json["filename"] as? String
|
||||||
|
else { return nil }
|
||||||
|
return EventAttachment(
|
||||||
|
id: id,
|
||||||
|
filename: filename,
|
||||||
|
contentType: json["content_type"] as? String ?? "application/octet-stream",
|
||||||
|
sizeBytes: json["size_bytes"] as? Int ?? 0,
|
||||||
|
hasThumbnail: json["has_thumb"] as? Bool ?? false,
|
||||||
|
uploadedBy: EventPerson.from(json["uploaded_by"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A partial-sync failure reported alongside an otherwise-successful
|
/// A partial-sync failure reported alongside an otherwise-successful
|
||||||
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
|
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
|
||||||
/// expired credentials) even though it's still enabled. Distinct from a
|
/// expired credentials) even though it's still enabled. Distinct from a
|
||||||
@@ -70,6 +112,10 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
// True for events from a birthday calendar — clients show a cake icon and the
|
// True for events from a birthday calendar — clients show a cake icon and the
|
||||||
// server bakes the age into `displayTitle`.
|
// server bakes the age into `displayTitle`.
|
||||||
var isBirthday: Bool = false
|
var isBirthday: Bool = false
|
||||||
|
// How many files hang off this event. The list payload carries only the
|
||||||
|
// count; the files themselves are fetched when the detail sheet opens.
|
||||||
|
// Absent on non-local sources and on busy-masked private events, so 0.
|
||||||
|
var attachmentCount: Int = 0
|
||||||
|
|
||||||
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||||
@@ -139,7 +185,8 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||||
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
|
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
|
||||||
readOnly: json["read_only"] as? Bool ?? false,
|
readOnly: json["read_only"] as? Bool ?? false,
|
||||||
isBirthday: json["is_birthday"] as? Bool ?? false
|
isBirthday: json["is_birthday"] as? Bool ?? false,
|
||||||
|
attachmentCount: json["attachment_count"] as? Int ?? 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ private let strings: [String: [String: String]] = [
|
|||||||
"event.title_placeholder": "Titel",
|
"event.title_placeholder": "Titel",
|
||||||
"event.allday": "Ganztägig",
|
"event.allday": "Ganztägig",
|
||||||
"event.private": "Privat",
|
"event.private": "Privat",
|
||||||
|
"event.attachments": "Anhänge",
|
||||||
|
"event.attachment_add": "Datei hinzufügen",
|
||||||
|
"event.attachment_hint": "Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.",
|
||||||
|
"event.attachment_failed": "Anhänge konnten nicht vollständig gespeichert werden.",
|
||||||
"event.start": "Start",
|
"event.start": "Start",
|
||||||
"event.end": "Ende",
|
"event.end": "Ende",
|
||||||
"event.location": "Ort",
|
"event.location": "Ort",
|
||||||
@@ -652,6 +656,10 @@ private let strings: [String: [String: String]] = [
|
|||||||
"event.title_placeholder": "Title",
|
"event.title_placeholder": "Title",
|
||||||
"event.allday": "All-day",
|
"event.allday": "All-day",
|
||||||
"event.private": "Private",
|
"event.private": "Private",
|
||||||
|
"event.attachments": "Attachments",
|
||||||
|
"event.attachment_add": "Add file",
|
||||||
|
"event.attachment_hint": "Max. 10 MB per file, 10 files. PDF, images, text.",
|
||||||
|
"event.attachment_failed": "Attachments could not be saved completely.",
|
||||||
"event.start": "Start",
|
"event.start": "Start",
|
||||||
"event.end": "End",
|
"event.end": "End",
|
||||||
"event.location": "Location",
|
"event.location": "Location",
|
||||||
|
|||||||
@@ -673,6 +673,82 @@ class CalendarrAPI {
|
|||||||
// MARK: – iCal import / export
|
// MARK: – iCal import / export
|
||||||
|
|
||||||
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
|
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
|
||||||
|
|
||||||
|
// MARK: - Attachments
|
||||||
|
|
||||||
|
/// Files on an event. Only local events can have any; the server answers
|
||||||
|
/// 404 for anything else, which surfaces as `serverError`.
|
||||||
|
func listAttachments(eventUid: String) async throws -> [EventAttachment] {
|
||||||
|
let path = "/api/local/events/\(eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid)/attachments"
|
||||||
|
let data = try await request(path)
|
||||||
|
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] ?? []
|
||||||
|
return arr.compactMap { EventAttachment.from(json: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload one file. The body is built by hand, exactly like `importICS`.
|
||||||
|
func uploadAttachment(eventUid: String, fileURL: URL) async throws -> EventAttachment {
|
||||||
|
// A URL from `.fileImporter` is security-scoped: without this the read
|
||||||
|
// fails outside the app's own container.
|
||||||
|
let scoped = fileURL.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { fileURL.stopAccessingSecurityScopedResource() } }
|
||||||
|
let fileData = try Data(contentsOf: fileURL)
|
||||||
|
return try await uploadAttachment(eventUid: eventUid,
|
||||||
|
data: fileData,
|
||||||
|
filename: fileURL.lastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadAttachment(eventUid: String, data fileData: Data, filename: String) async throws -> EventAttachment {
|
||||||
|
let encodedUid = eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid
|
||||||
|
guard let url = URL(string: baseURL + "/api/local/events/\(encodedUid)/attachments") else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var bodyData = Data()
|
||||||
|
let name = filename.isEmpty ? "anhang" : filename
|
||||||
|
bodyData.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\n".data(using: .utf8)!)
|
||||||
|
// The server sniffs the bytes and ignores this header, but something
|
||||||
|
// has to be sent for the part to be well formed.
|
||||||
|
bodyData.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||||
|
bodyData.append(fileData)
|
||||||
|
bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
req.httpBody = bodyData
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
if status == 401 { throw APIError.unauthorized }
|
||||||
|
if status >= 400 {
|
||||||
|
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String
|
||||||
|
?? "Fehler \(status)"
|
||||||
|
throw APIError.serverError(msg)
|
||||||
|
}
|
||||||
|
guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||||
|
let att = EventAttachment.from(json: json) else {
|
||||||
|
throw APIError.serverError("Unerwartete Antwort")
|
||||||
|
}
|
||||||
|
return att
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAttachment(id: Int) async throws {
|
||||||
|
_ = try await request("/api/local/attachments/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw bytes, for saving or sharing.
|
||||||
|
func downloadAttachment(id: Int) async throws -> Data {
|
||||||
|
try await request("/api/local/attachments/\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small JPEG preview, or nil when there is none. Never throws: a missing
|
||||||
|
/// thumbnail just means the row shows its type icon.
|
||||||
|
func attachmentThumbnail(id: Int) async -> Data? {
|
||||||
|
try? await request("/api/local/attachments/\(id)/thumb")
|
||||||
|
}
|
||||||
|
|
||||||
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
|
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
|
||||||
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
|
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
|
||||||
let fileData = try Data(contentsOf: fileURL)
|
let fileData = try Data(contentsOf: fileURL)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// `.sheet(item:)` needs Identifiable and URL is not. A retroactive conformance
|
||||||
|
/// on a stdlib type would be visible module-wide, so wrap it instead.
|
||||||
|
private struct SharedFile: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let url: URL
|
||||||
|
}
|
||||||
|
|
||||||
struct EventDetailSheet: View {
|
struct EventDetailSheet: View {
|
||||||
let event: CalEvent
|
let event: CalEvent
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@@ -15,6 +22,10 @@ struct EventDetailSheet: View {
|
|||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var isDeleting = false
|
@State private var isDeleting = false
|
||||||
@State private var showCopySheet = false
|
@State private var showCopySheet = false
|
||||||
|
@State private var attachments: [EventAttachment] = []
|
||||||
|
@State private var thumbnails: [Int: UIImage] = [:]
|
||||||
|
@State private var loadingAttachments = false
|
||||||
|
@State private var sharedFile: SharedFile?
|
||||||
|
|
||||||
private let timeFmt: DateFormatter = {
|
private let timeFmt: DateFormatter = {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
@@ -111,6 +122,43 @@ struct EventDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachments exist on local events only; the payload carries
|
||||||
|
// just the count, so the list is fetched when the sheet opens.
|
||||||
|
if event.source == "local" && event.attachmentCount > 0 {
|
||||||
|
Section(L10n.t("event.attachments", appLang)) {
|
||||||
|
if loadingAttachments && attachments.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
ForEach(attachments) { att in
|
||||||
|
Button {
|
||||||
|
Task { await shareAttachment(att) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let image = thumbnails[att.id] {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
} else {
|
||||||
|
Image(systemName: att.symbolName)
|
||||||
|
.frame(width: 32)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(att.filename)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Text(att.displaySize)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !store.writableCalendars.isEmpty {
|
if !store.writableCalendars.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
@@ -170,9 +218,42 @@ struct EventDetailSheet: View {
|
|||||||
await onDone(nil, true)
|
await onDone(nil, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task(id: event.id) { await loadAttachments() }
|
||||||
|
.sheet(item: $sharedFile) { shared in
|
||||||
|
ActivityView(items: [shared.url])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadAttachments() async {
|
||||||
|
guard event.source == "local", event.attachmentCount > 0 else {
|
||||||
|
attachments = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingAttachments = true
|
||||||
|
defer { loadingAttachments = false }
|
||||||
|
guard let loaded = try? await api.listAttachments(eventUid: event.id) else { return }
|
||||||
|
attachments = loaded
|
||||||
|
// Thumbnails are small server-rendered JPEGs, so decoding them here is
|
||||||
|
// cheap and needs no image-loading dependency.
|
||||||
|
for att in loaded where att.hasThumbnail {
|
||||||
|
if let data = await api.attachmentThumbnail(id: att.id),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
thumbnails[att.id] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download to a temporary file and hand it to the share sheet, so the user
|
||||||
|
/// can save it to Files or open it in another app.
|
||||||
|
private func shareAttachment(_ att: EventAttachment) async {
|
||||||
|
guard let data = try? await api.downloadAttachment(id: att.id) else { return }
|
||||||
|
let name = att.filename.isEmpty ? "anhang" : att.filename
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||||
|
guard (try? data.write(to: url)) != nil else { return }
|
||||||
|
sharedFile = SharedFile(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
private func deleteEvent() async {
|
private func deleteEvent() async {
|
||||||
isDeleting = true
|
isDeleting = true
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct EventEditorSheet: View {
|
struct EventEditorSheet: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@@ -23,6 +24,12 @@ struct EventEditorSheet: View {
|
|||||||
@State private var isPrivate = false
|
@State private var isPrivate = false
|
||||||
@State private var reminders: [Int] = []
|
@State private var reminders: [Int] = []
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
|
// Files picked before the event exists are staged and uploaded after it is
|
||||||
|
// created; existing ones are removed only on save, so Cancel really cancels.
|
||||||
|
@State private var stagedFiles: [URL] = []
|
||||||
|
@State private var existingAttachments: [EventAttachment] = []
|
||||||
|
@State private var removedAttachmentIds: Set<Int> = []
|
||||||
|
@State private var showFileImporter = false
|
||||||
@State private var error = ""
|
@State private var error = ""
|
||||||
|
|
||||||
private var isEditing: Bool { editingEvent != nil }
|
private var isEditing: Bool { editingEvent != nil }
|
||||||
@@ -114,6 +121,44 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(remindersDisabled)
|
.disabled(remindersDisabled)
|
||||||
|
|
||||||
|
Section(L10n.t("event.attachments", appLang)) {
|
||||||
|
ForEach(existingAttachments.filter { !removedAttachmentIds.contains($0.id) }) { att in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: att.symbolName).foregroundStyle(.secondary)
|
||||||
|
Text(att.filename).lineLimit(1).truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Text(att.displaySize).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
removedAttachmentIds.insert(att.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(stagedFiles, id: \.self) { url in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "doc.badge.plus").foregroundStyle(Color.accentColor)
|
||||||
|
Text(url.lastPathComponent).lineLimit(1).truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
stagedFiles.removeAll { $0 == url }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
showFileImporter = true
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("event.attachment_add", appLang), systemImage: "paperclip")
|
||||||
|
}
|
||||||
|
.disabled(attachmentSlotsUsed >= 10)
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("event.attachment_hint", appLang))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(L10n.t("event.color_section", appLang)) {
|
Section(L10n.t("event.color_section", appLang)) {
|
||||||
@@ -161,6 +206,15 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { setup() }
|
.onAppear { setup() }
|
||||||
|
.task { await loadExistingAttachments() }
|
||||||
|
.fileImporter(isPresented: $showFileImporter,
|
||||||
|
allowedContentTypes: Self.allowedAttachmentTypes,
|
||||||
|
allowsMultipleSelection: true) { result in
|
||||||
|
guard case .success(let urls) = result else { return }
|
||||||
|
for url in urls where attachmentSlotsUsed < 10 {
|
||||||
|
stagedFiles.append(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: startDate) { oldStart, newStart in
|
.onChange(of: startDate) { oldStart, newStart in
|
||||||
guard newStart >= endDate else { return }
|
guard newStart >= endDate else { return }
|
||||||
let duration = endDate.timeIntervalSince(oldStart)
|
let duration = endDate.timeIntervalSince(oldStart)
|
||||||
@@ -169,6 +223,42 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors the server allowlist. `.webP` needs iOS 14, which is well below
|
||||||
|
/// the deployment target; markdown has no system-provided type.
|
||||||
|
private static var allowedAttachmentTypes: [UTType] {
|
||||||
|
var types: [UTType] = [.pdf, .png, .jpeg, .gif, .plainText, .commaSeparatedText]
|
||||||
|
if let webp = UTType("org.webmproject.webp") { types.append(webp) }
|
||||||
|
if let md = UTType("net.daringfireball.markdown") { types.append(md) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
private var attachmentSlotsUsed: Int {
|
||||||
|
existingAttachments.filter { !removedAttachmentIds.contains($0.id) }.count + stagedFiles.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadExistingAttachments() async {
|
||||||
|
guard let ev = editingEvent, ev.source == "local", ev.attachmentCount > 0 else { return }
|
||||||
|
existingAttachments = (try? await api.listAttachments(eventUid: ev.id)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applied after the event itself was saved. A failure here leaves the
|
||||||
|
/// event in place — report it rather than claiming everything worked.
|
||||||
|
private func applyAttachmentChanges(uid: String) async {
|
||||||
|
var failed = 0
|
||||||
|
for id in removedAttachmentIds {
|
||||||
|
do { try await api.deleteAttachment(id: id) } catch { failed += 1 }
|
||||||
|
}
|
||||||
|
for url in stagedFiles {
|
||||||
|
do { _ = try await api.uploadAttachment(eventUid: uid, fileURL: url) }
|
||||||
|
catch { failed += 1 }
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
error = L10n.t("event.attachment_failed", appLang)
|
||||||
|
}
|
||||||
|
stagedFiles = []
|
||||||
|
removedAttachmentIds = []
|
||||||
|
}
|
||||||
|
|
||||||
private func setup() {
|
private func setup() {
|
||||||
if let ev = editingEvent {
|
if let ev = editingEvent {
|
||||||
title = ev.title
|
title = ev.title
|
||||||
@@ -232,6 +322,7 @@ struct EventEditorSheet: View {
|
|||||||
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
||||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||||
isPrivate: isPrivate, reminders: reminders)
|
isPrivate: isPrivate, reminders: reminders)
|
||||||
|
await applyAttachmentChanges(uid: ev.id)
|
||||||
case "homeassistant":
|
case "homeassistant":
|
||||||
// No update API exists – delete the old event and recreate with new data.
|
// No update API exists – delete the old event and recreate with new data.
|
||||||
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||||
@@ -249,10 +340,12 @@ struct EventEditorSheet: View {
|
|||||||
} else {
|
} else {
|
||||||
switch cal.source {
|
switch cal.source {
|
||||||
case "local":
|
case "local":
|
||||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
let created = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
location: location, description: notes, color: colorVal,
|
location: location, description: notes, color: colorVal,
|
||||||
isPrivate: isPrivate, reminders: reminders)
|
isPrivate: isPrivate, reminders: reminders)
|
||||||
|
// Only now does a uid exist to attach the staged files to.
|
||||||
|
await applyAttachmentChanges(uid: created.id)
|
||||||
case "google":
|
case "google":
|
||||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
|
|||||||
Reference in New Issue
Block a user