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.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
|
||||
}
|
||||
},
|
||||
|
||||
+74
-4
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user