Compare commits

..
2 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 a1f15e33aa feat(android): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment und StagedAttachment als
Modelle, fuenf Retrofit-Endpunkte plus Repository-Wrapper. Die
Event-Endpunkte liefern wie bisher den rohen Body und werden von Hand
geparst, passend zum iOS-Client.

Die Detailansicht zeigt die Liste und speichert eine Datei ueber SAF --
derselbe Zweischritt wie beim ICS-Export, damit weder ein FileProvider im
Manifest noch eine Speicherberechtigung noetig wird. Der Editor staged
neue Dateien und wendet sie erst beim Speichern an; createLocalEvent gibt
dafuer jetzt die neue UID zurueck.

Thumbnails werden mit BitmapFactory dekodiert statt eine Bildbibliothek
aufzunehmen: es sind serverseitig auf 320 px verkleinerte JPEGs, dafuer
lohnt Coil nicht.

strOrNull ist aus dem Companion von CalEvent auf Dateiebene gewandert,
damit EventAttachment es auch nutzen kann.

Bewusst TextOverflow.Ellipsis statt MiddleEllipsis: die Compose-BOM
2023.10.01 kennt letzteres noch nicht.

Nicht kompiliert: auf diesem Rechner gibt es weder JDK noch Android-SDK.
Muss auf einer Maschine mit beidem gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:39:16 +02:00
ScarriffleandClaude Opus 5 3b649ec310 feat(ios): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment als Modell, API-Methoden
fuer Liste, Upload, Download, Thumbnail und Loeschen. Der Multipart-Body
ist von importICS uebernommen.

Die Detailansicht laedt die Liste beim Oeffnen und teilt eine Datei ueber
das System-Share-Sheet; der Editor staged neue Dateien und wendet sie
erst nach dem Speichern an, weil ein neuer Termin vorher keine UID hat.

EventAttachment liegt bewusst in CalEvent.swift statt in einer eigenen
Datei: eine hier angelegte .swift-Datei waere in keinem Xcode-Target
Mitglied und wuerde schlicht nicht mitkompiliert -- das faellt erst auf
dem Mac auf.

Die URL fuer das Share-Sheet steckt in einem kleinen Identifiable-Wrapper,
weil URL das nicht ist und eine nachtraegliche Konformitaet auf einen
Stdlib-Typ modulweit sichtbar waere.

Nicht kompiliert: auf diesem Rechner gibt es kein Xcode. Muss auf dem Mac
gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:32:01 +02:00
13 changed files with 730 additions and 24 deletions
@@ -11,6 +11,7 @@ import com.scarriffle.calendarr.data.remote.jsonBody
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount
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.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
@@ -27,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import retrofit2.HttpException
import java.time.Instant
@@ -437,9 +439,13 @@ class CalendarRepository @Inject constructor(
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess()
): String? = guarded {
val resp = api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
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(
@@ -504,6 +510,50 @@ class CalendarRepository @Inject constructor(
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 {
val resp = api.exportCalendar(calendarId)
resp.ensureSuccess()
@@ -18,6 +18,7 @@ import retrofit2.http.HTTP
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Streaming
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
@@ -86,6 +87,28 @@ interface CalendarrApi {
@GET("api/local/calendars/{id}/export")
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 ----
@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. */
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(
val id: String,
val url: String,
@@ -41,6 +112,10 @@ data class CalEvent(
// True for events from a birthday calendar — clients show a cake icon and
// the server bakes the age into `displayTitle`.
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)
@@ -70,17 +145,6 @@ data class CalEvent(
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). */
private fun personFrom(json: JSONObject?, key: String): EventPerson? {
val obj = json?.optJSONObject(key) ?: return null
@@ -139,6 +203,7 @@ data class CalEvent(
} ?: emptyList(),
readOnly = json.optBoolean("read_only", 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.delete_confirm" to "Diesen Termin löschen?",
"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_before" to "vorher",
"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.delete_confirm" to "Delete this event?",
"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_before" to "before",
"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.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -156,6 +157,19 @@ fun CalendarScreen(
var viewMenuOpen by remember { mutableStateOf(false) }
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 overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
@@ -328,9 +342,23 @@ fun CalendarScreen(
) {
val ev = lastDetail
if (ev != null) {
val attachments by vm.attachments.collectAsState()
val attachmentThumbs by vm.attachmentThumbs.collectAsState()
LaunchedEffect(ev.id) { vm.loadAttachments(ev) }
EventDetailScreen(
event = ev,
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 },
onEdit = {
detailEvent = null
@@ -349,14 +377,19 @@ fun CalendarScreen(
}
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(
request = req,
writableCalendars = state.writableCalendars,
existingAttachments = if (req.existing?.source == "local") editorAttachments else emptyList(),
onDismiss = { editor = null },
defaultDurationMinutes = vm.defaultEventDurationMinutes,
reminderDisabledKeys = state.reminderDisabledKeys,
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error ->
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, staged, removed) { error ->
if (error == null) editor = null
}
},
@@ -1,5 +1,8 @@
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.viewModelScope
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.domain.model.CalEvent
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.GroupMember
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.util.Dates
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,9 +22,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -731,20 +738,25 @@ class CalendarViewModel @Inject constructor(
color: String?,
isPrivate: Boolean,
reminders: List<Int> = emptyList(),
stagedAttachments: List<StagedAttachment> = emptyList(),
removedAttachmentIds: Set<Int> = emptySet(),
onResult: (String?) -> Unit,
) {
viewModelScope.launch {
val result = runCatching {
if (existing != null && existing.source == calendar.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)
"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)
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 {
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) }
@@ -756,15 +768,73 @@ class CalendarViewModel @Inject constructor(
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
reminders: List<Int> = emptyList(),
stagedAttachments: List<StagedAttachment> = emptyList(),
) {
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)
"google" -> repository.createGoogleEvent(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) {
viewModelScope.launch {
val result = runCatching {
@@ -1,6 +1,8 @@
package com.scarriffle.calendarr.ui.event
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.rememberScrollState
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.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
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.unit.dp
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.calendar.eventDateRange
import com.scarriffle.calendarr.ui.tr
@@ -66,6 +74,9 @@ fun EventDetailScreen(
onEdit: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
attachments: List<EventAttachment> = emptyList(),
attachmentThumbs: Map<Int, ImageBitmap> = emptyMap(),
onOpenAttachment: (EventAttachment) -> Unit = {},
) {
val lang = LocalLang.current
var confirmDelete by remember { mutableStateOf(false) }
@@ -118,6 +129,59 @@ fun EventDetailScreen(
}
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))
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -166,3 +230,10 @@ private fun DetailRow(icon: ImageVector, text: String) {
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.verticalScroll
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.AttachFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.IconButton
@@ -44,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.unit.dp
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.StagedAttachment
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
@@ -79,7 +85,8 @@ fun EventEditorSheet(
onDismiss: () -> Unit,
defaultDurationMinutes: Int = 60,
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 context = LocalContext.current
@@ -126,6 +133,28 @@ fun EventEditorSheet(
}
var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) }
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) }
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy")
@@ -282,6 +311,42 @@ fun EventEditorSheet(
},
) { Text(tr("event.reminder_add")) }
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
@@ -325,7 +390,9 @@ fun EventEditorSheet(
end = endDate.atTime(endTime).atZone(zone).toInstant()
}
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(),
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()
+48 -1
View File
@@ -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
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
/// 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
// server bakes the age into `displayTitle`.
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.
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 },
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
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.allday": "Ganztägig",
"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.end": "Ende",
"event.location": "Ort",
@@ -652,6 +656,10 @@ private let strings: [String: [String: String]] = [
"event.title_placeholder": "Title",
"event.allday": "All-day",
"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.end": "End",
"event.location": "Location",
@@ -673,6 +673,82 @@ class CalendarrAPI {
// MARK: – iCal import / export
/// 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]) {
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
let fileData = try Data(contentsOf: fileURL)
@@ -1,5 +1,12 @@
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 {
let event: CalEvent
let api: CalendarrAPI
@@ -15,6 +22,10 @@ struct EventDetailSheet: View {
@State private var showDeleteConfirm = false
@State private var isDeleting = 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 = {
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 {
Section {
Button {
@@ -170,9 +218,42 @@ struct EventDetailSheet: View {
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 {
isDeleting = true
do {
@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
struct EventEditorSheet: View {
let api: CalendarrAPI
@@ -23,6 +24,12 @@ struct EventEditorSheet: View {
@State private var isPrivate = false
@State private var reminders: [Int] = []
@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 = ""
private var isEditing: Bool { editingEvent != nil }
@@ -114,6 +121,44 @@ struct EventEditorSheet: View {
}
}
.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)) {
@@ -161,6 +206,15 @@ struct EventEditorSheet: View {
}
}
.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
guard newStart >= endDate else { return }
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() {
if let ev = editingEvent {
title = ev.title
@@ -232,6 +322,7 @@ struct EventEditorSheet: View {
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders)
await applyAttachmentChanges(uid: ev.id)
case "homeassistant":
// No update API exists – delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
@@ -249,10 +340,12 @@ struct EventEditorSheet: View {
} else {
switch cal.source {
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,
location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders)
// Only now does a uid exist to attach the staged files to.
await applyAttachmentChanges(uid: created.id)
case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,