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>
This commit is contained in:
Scarriffle
2026-09-14 10:39:16 +02:00
co-authored by Claude Opus 5
parent 3b649ec310
commit a1f15e33aa
8 changed files with 423 additions and 22 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.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.domain.model.CalendarShareEntry import com.scarriffle.calendarr.domain.model.CalendarShareEntry
import com.scarriffle.calendarr.domain.model.DirectoryUser import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.domain.model.Group
@@ -27,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import retrofit2.HttpException import retrofit2.HttpException
import java.time.Instant import java.time.Instant
@@ -437,9 +439,13 @@ class CalendarRepository @Inject constructor(
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null, isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null, rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded { ): String? = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid)) val resp = api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess() resp.ensureSuccess()
// The response is the full event dict; its "id" is the uid attachments
// are addressed by. Callers that don't need it simply ignore it.
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("id").ifBlank { null } }
.getOrNull()
} }
suspend fun updateLocalEvent( suspend fun updateLocalEvent(
@@ -504,6 +510,50 @@ class CalendarRepository @Inject constructor(
return importIcs(calendarId, part) return importIcs(calendarId, part)
} }
// ---- Attachments ----
suspend fun listAttachments(uid: String): List<EventAttachment> = guarded {
val resp = api.listAttachments(uid)
resp.ensureSuccess()
val arr = JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.let { EventAttachment.fromJson(it)?.let(::add) }
}
}
}
/** Upload one file. The server sniffs the bytes, so the media type here is
* only about forming a valid multipart part. */
suspend fun uploadAttachment(uid: String, bytes: ByteArray, filename: String,
mimeType: String?): EventAttachment = guarded {
val body = bytes.toRequestBody((mimeType ?: "application/octet-stream").toMediaTypeOrNull())
val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body)
val resp = api.uploadAttachment(uid, part)
resp.ensureSuccess()
EventAttachment.fromJson(JSONObject(resp.body()?.string() ?: "{}"))
?: error("Unerwartete Antwort")
}
suspend fun downloadAttachment(id: Int): ByteArray = guarded {
val resp = api.downloadAttachment(id)
resp.ensureSuccess()
resp.body()?.bytes() ?: ByteArray(0)
}
/** Thumbnail bytes, or null when there is none — a missing preview just
* means the row shows its type icon. */
suspend fun attachmentThumbnail(id: Int): ByteArray? = try {
val resp = api.attachmentThumbnail(id)
if (resp.isSuccessful) resp.body()?.bytes() else null
} catch (e: Exception) {
null
}
suspend fun deleteAttachment(id: Int): Unit = guarded {
api.deleteAttachment(id).ensureSuccess()
}
suspend fun exportIcs(calendarId: Int): ByteArray = guarded { suspend fun exportIcs(calendarId: Int): ByteArray = guarded {
val resp = api.exportCalendar(calendarId) val resp = api.exportCalendar(calendarId)
resp.ensureSuccess() resp.ensureSuccess()
@@ -18,6 +18,7 @@ import retrofit2.http.HTTP
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.PATCH import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Streaming
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
@@ -86,6 +87,28 @@ interface CalendarrApi {
@GET("api/local/calendars/{id}/export") @GET("api/local/calendars/{id}/export")
suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody> suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody>
// ---- Attachments ----
// Like the event endpoints, these return the raw body and are parsed by
// hand, mirroring the iOS client.
@GET("api/local/events/{uid}/attachments")
suspend fun listAttachments(@Path("uid") uid: String): Response<ResponseBody>
@Multipart
@POST("api/local/events/{uid}/attachments")
suspend fun uploadAttachment(@Path("uid") uid: String,
@Part part: MultipartBody.Part): Response<ResponseBody>
@Streaming
@GET("api/local/attachments/{id}")
suspend fun downloadAttachment(@Path("id") id: Int): Response<ResponseBody>
@GET("api/local/attachments/{id}/thumb")
suspend fun attachmentThumbnail(@Path("id") id: Int): Response<ResponseBody>
@DELETE("api/local/attachments/{id}")
suspend fun deleteAttachment(@Path("id") id: Int): Response<ResponseBody>
// ---- Groups ---- // ---- Groups ----
@GET("api/groups/") @GET("api/groups/")
@@ -11,6 +11,77 @@ import java.time.Instant
/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */ /** Creator (or owner, in the group combined view) of an event. id is null for imported events. */
data class EventPerson(val id: Int?, val displayName: String) data class EventPerson(val id: Int?, val displayName: String)
/**
* Read a string field, treating JSON null correctly. Android's
* [JSONObject.optString] returns the literal string "null" for a JSON null
* value, which previously made every event blue (color "null" → unparseable
* → fallback) and showed "null" for empty location/notes.
*
* File-scope rather than inside CalEvent's companion, so EventAttachment can
* use it as well.
*/
private fun JSONObject.strOrNull(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
}
/**
* A file attached to an event. Mirrors iOS `EventAttachment`.
*/
data class EventAttachment(
val id: Int,
val filename: String,
val contentType: String,
val sizeBytes: Int,
val hasThumbnail: Boolean,
val uploadedBy: EventPerson? = null,
) {
val isImage: Boolean get() = contentType.startsWith("image/")
companion object {
fun fromJson(json: JSONObject): EventAttachment? {
val id = (json.opt("id") as? Number)?.toInt() ?: return null
val filename = json.strOrNull("filename") ?: return null
return EventAttachment(
id = id,
filename = filename,
contentType = json.strOrNull("content_type") ?: "application/octet-stream",
sizeBytes = json.optInt("size_bytes", 0),
hasThumbnail = json.optBoolean("has_thumb", false),
uploadedBy = json.optJSONObject("uploaded_by")?.let { obj ->
obj.strOrNull("display_name")?.let { name ->
EventPerson((obj.opt("id") as? Number)?.toInt(), name)
}
},
)
}
}
}
/**
* A file picked in the editor but not uploaded yet. The bytes are read at pick
* time (the content URI may not survive the save), and the event it belongs to
* may not exist yet.
*/
data class StagedAttachment(
val bytes: ByteArray,
val filename: String,
val mimeType: String?,
) {
// ByteArray uses identity equality, so a data class holding one needs these
// spelled out or two distinct picks of the same file compare unequal in a
// list diff.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is StagedAttachment) return false
return filename == other.filename && mimeType == other.mimeType &&
bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int =
31 * (31 * filename.hashCode() + (mimeType?.hashCode() ?: 0)) + bytes.contentHashCode()
}
data class CalEvent( data class CalEvent(
val id: String, val id: String,
val url: String, val url: String,
@@ -41,6 +112,10 @@ data class CalEvent(
// True for events from a birthday calendar — clients show a cake icon and // True for events from a birthday calendar — clients show a cake icon and
// the server bakes the age into `displayTitle`. // the server bakes the age into `displayTitle`.
val isBirthday: Boolean = false, val isBirthday: Boolean = false,
// How many files hang off this event. The list payload carries only the
// count; the files themselves are fetched when the detail screen opens.
// Absent on non-local sources and on busy-masked private events, so 0.
val attachmentCount: Int = 0,
) { ) {
/** /**
* Title to render: the server-decorated one (birthday age, group prefix) * Title to render: the server-decorated one (birthday age, group prefix)
@@ -70,17 +145,6 @@ data class CalEvent(
return FALLBACK_PALETTE[idx] return FALLBACK_PALETTE[idx]
} }
/**
* Read a string field, treating JSON null correctly. Android's
* [JSONObject.optString] returns the literal string "null" for a JSON
* null value, which previously made every event blue (color "null" →
* unparseable → fallback) and showed "null" for empty location/notes.
*/
private fun JSONObject.strOrNull(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
}
/** Parse a {id, display_name} person object (creator/owner). */ /** Parse a {id, display_name} person object (creator/owner). */
private fun personFrom(json: JSONObject?, key: String): EventPerson? { private fun personFrom(json: JSONObject?, key: String): EventPerson? {
val obj = json?.optJSONObject(key) ?: return null val obj = json?.optJSONObject(key) ?: return null
@@ -139,6 +203,7 @@ data class CalEvent(
} ?: emptyList(), } ?: emptyList(),
readOnly = json.optBoolean("read_only", false), readOnly = json.optBoolean("read_only", false),
isBirthday = json.optBoolean("is_birthday", false), isBirthday = json.optBoolean("is_birthday", false),
attachmentCount = json.optInt("attachment_count", 0),
) )
} }
} }
@@ -122,6 +122,8 @@ object L10n {
"event.save" to "Sichern", "event.add" to "Hinzufügen", "event.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?", "event.delete_confirm" to "Diesen Termin löschen?",
"event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen", "event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen",
"event.attachments" to "Anhänge", "event.attachment_add" to "Datei hinzufügen",
"event.attachment_hint" to "Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.",
"event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit", "event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit",
"event.reminder_before" to "vorher", "event.reminder_before" to "vorher",
"event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden", "event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden",
@@ -318,6 +320,8 @@ object L10n {
"event.save" to "Save", "event.add" to "Add", "event.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?", "event.delete_confirm" to "Delete this event?",
"event.reminders" to "Reminders", "event.reminder_add" to "Add reminder", "event.reminders" to "Reminders", "event.reminder_add" to "Add reminder",
"event.attachments" to "Attachments", "event.attachment_add" to "Add file",
"event.attachment_hint" to "Max. 10 MB per file, 10 files. PDF, images, text.",
"event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time", "event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time",
"event.reminder_before" to "before", "event.reminder_before" to "before",
"event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours", "event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours",
@@ -52,6 +52,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -156,6 +157,19 @@ fun CalendarScreen(
var viewMenuOpen by remember { mutableStateOf(false) } var viewMenuOpen by remember { mutableStateOf(false) }
var detailEvent by remember { mutableStateOf<CalEvent?>(null) } var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
// Downloading an attachment is two steps, like the .ics export: fetch the
// bytes, then let the user choose where to put them. Going through SAF
// means no FileProvider entry and no storage permission.
var pendingAttachment by remember { mutableStateOf<ByteArray?>(null) }
val saveAttachment = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("*/*")
) { uri ->
val bytes = pendingAttachment
pendingAttachment = null
if (uri != null && bytes != null) {
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } }
}
}
var editor by remember { mutableStateOf<EditorRequest?>(null) } var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) } var overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) } var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
@@ -328,9 +342,23 @@ fun CalendarScreen(
) { ) {
val ev = lastDetail val ev = lastDetail
if (ev != null) { if (ev != null) {
val attachments by vm.attachments.collectAsState()
val attachmentThumbs by vm.attachmentThumbs.collectAsState()
LaunchedEffect(ev.id) { vm.loadAttachments(ev) }
EventDetailScreen( EventDetailScreen(
event = ev, event = ev,
currentUserId = vm.currentUserId, currentUserId = vm.currentUserId,
attachments = attachments,
attachmentThumbs = attachmentThumbs,
onOpenAttachment = { att ->
// Fetch the bytes, then let the user pick where to save
// them — the same two-step the .ics export already uses,
// which needs no FileProvider entry in the manifest.
vm.downloadAttachment(att) { bytes ->
pendingAttachment = bytes
saveAttachment.launch(att.filename)
}
},
onClose = { detailEvent = null }, onClose = { detailEvent = null },
onEdit = { onEdit = {
detailEvent = null detailEvent = null
@@ -349,14 +377,19 @@ fun CalendarScreen(
} }
editor?.let { req -> editor?.let { req ->
// The editor needs the event's current attachments to offer removing
// them; loading is the same call the detail screen makes.
LaunchedEffect(req.existing?.id) { req.existing?.let { vm.loadAttachments(it) } }
val editorAttachments by vm.attachments.collectAsState()
EventEditorSheet( EventEditorSheet(
request = req, request = req,
writableCalendars = state.writableCalendars, writableCalendars = state.writableCalendars,
existingAttachments = if (req.existing?.source == "local") editorAttachments else emptyList(),
onDismiss = { editor = null }, onDismiss = { editor = null },
defaultDurationMinutes = vm.defaultEventDurationMinutes, defaultDurationMinutes = vm.defaultEventDurationMinutes,
reminderDisabledKeys = state.reminderDisabledKeys, reminderDisabledKeys = state.reminderDisabledKeys,
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders -> onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error -> vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed) { error ->
if (error == null) editor = null if (error == null) editor = null
} }
}, },
@@ -1,5 +1,8 @@
package com.scarriffle.calendarr.ui.calendar package com.scarriffle.calendarr.ui.calendar
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
@@ -7,9 +10,11 @@ import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.SyncError import com.scarriffle.calendarr.data.SyncError
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember import com.scarriffle.calendarr.domain.model.GroupMember
import com.scarriffle.calendarr.domain.model.LocalCalendar import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.StagedAttachment
import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates import com.scarriffle.calendarr.util.Dates
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -17,9 +22,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
@@ -731,20 +738,25 @@ class CalendarViewModel @Inject constructor(
color: String?, color: String?,
isPrivate: Boolean, isPrivate: Boolean,
reminders: List<Int> = emptyList(), reminders: List<Int> = emptyList(),
stagedAttachments: List<StagedAttachment> = emptyList(),
removedAttachmentIds: Set<Int> = emptySet(),
onResult: (String?) -> Unit, onResult: (String?) -> Unit,
) { ) {
viewModelScope.launch { viewModelScope.launch {
val result = runCatching { val result = runCatching {
if (existing != null && existing.source == calendar.source) { if (existing != null && existing.source == calendar.source) {
when (existing.source) { when (existing.source) {
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders) "local" -> {
repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
applyAttachmentChanges(existing.id, stagedAttachments, removedAttachmentIds)
}
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders) else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders, stagedAttachments)
} }
} else { } else {
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders) createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders, stagedAttachments)
} }
} }
result.onSuccess { afterMutation(); onResult(null) } result.onSuccess { afterMutation(); onResult(null) }
@@ -756,15 +768,73 @@ class CalendarViewModel @Inject constructor(
calendar: WritableCalendar, title: String, start: Instant, end: Instant, calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean, isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
reminders: List<Int> = emptyList(), reminders: List<Int> = emptyList(),
stagedAttachments: List<StagedAttachment> = emptyList(),
) { ) {
when (calendar.source) { when (calendar.source) {
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders) "local" -> {
// Only now does a uid exist to attach the staged files to.
val uid = repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
if (uid != null) applyAttachmentChanges(uid, stagedAttachments, emptySet())
}
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
} }
} }
// ---- Attachments ----
// The event payload carries only a count, so the list is fetched when the
// detail screen opens. Thumbnails are small server-rendered JPEGs, decoded
// here rather than pulling in an image-loading dependency for them.
private val _attachments = MutableStateFlow<List<EventAttachment>>(emptyList())
val attachments: StateFlow<List<EventAttachment>> = _attachments.asStateFlow()
private val _attachmentThumbs = MutableStateFlow<Map<Int, ImageBitmap>>(emptyMap())
val attachmentThumbs: StateFlow<Map<Int, ImageBitmap>> = _attachmentThumbs.asStateFlow()
fun loadAttachments(event: CalEvent) {
clearAttachments()
if (event.source != "local" || event.attachmentCount <= 0) return
viewModelScope.launch {
val loaded = runCatching { repository.listAttachments(event.id) }.getOrDefault(emptyList())
_attachments.value = loaded
val thumbs = mutableMapOf<Int, ImageBitmap>()
for (att in loaded.filter { it.hasThumbnail }) {
val bytes = repository.attachmentThumbnail(att.id) ?: continue
val bitmap = withContext(Dispatchers.IO) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} ?: continue
thumbs[att.id] = bitmap.asImageBitmap()
}
_attachmentThumbs.value = thumbs
}
}
/** Applied after the event itself was saved; a failure leaves the event. */
private suspend fun applyAttachmentChanges(
uid: String,
staged: List<StagedAttachment>,
removedIds: Set<Int>,
) {
for (id in removedIds) runCatching { repository.deleteAttachment(id) }
for (file in staged) {
runCatching { repository.uploadAttachment(uid, file.bytes, file.filename, file.mimeType) }
}
}
fun clearAttachments() {
_attachments.value = emptyList()
_attachmentThumbs.value = emptyMap()
}
/** Download the bytes and hand them to the caller (which writes them via SAF). */
fun downloadAttachment(att: EventAttachment, onReady: (ByteArray) -> Unit) {
viewModelScope.launch {
runCatching { repository.downloadAttachment(att.id) }.onSuccess(onReady)
}
}
fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) { fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val result = runCatching { val result = runCatching {
@@ -1,6 +1,8 @@
package com.scarriffle.calendarr.ui.event package com.scarriffle.calendarr.ui.event
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -14,8 +16,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
@@ -51,6 +58,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.calendar.eventDateRange import com.scarriffle.calendarr.ui.calendar.eventDateRange
import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.ui.tr
@@ -66,6 +74,9 @@ fun EventDetailScreen(
onEdit: () -> Unit, onEdit: () -> Unit,
onCopy: () -> Unit, onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
attachments: List<EventAttachment> = emptyList(),
attachmentThumbs: Map<Int, ImageBitmap> = emptyMap(),
onOpenAttachment: (EventAttachment) -> Unit = {},
) { ) {
val lang = LocalLang.current val lang = LocalLang.current
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
@@ -118,6 +129,59 @@ fun EventDetailScreen(
} }
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private")) if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
// Attachments exist on local events only; the payload carries
// just the count, so the list arrives once the screen is open.
if (event.source == "local" && event.attachmentCount > 0) {
Spacer(Modifier.height(12.dp))
Text(
tr("event.attachments"),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
attachments.forEach { att ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenAttachment(att) }
.padding(vertical = 8.dp),
) {
val thumb = attachmentThumbs[att.id]
if (thumb != null) {
Image(
bitmap = thumb,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp)),
)
} else {
Icon(
Icons.Filled.AttachFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp),
)
}
Spacer(Modifier.width(12.dp))
Text(
att.filename,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
formatAttachmentSize(att.sizeBytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.ContentCopy, contentDescription = null) Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -166,3 +230,10 @@ private fun DetailRow(icon: ImageVector, text: String) {
Text(text, style = MaterialTheme.typography.bodyLarge) Text(text, style = MaterialTheme.typography.bodyLarge)
} }
} }
/** Human-readable size, matching what the web and iOS show. */
private fun formatAttachmentSize(bytes: Int): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0))
}
@@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -44,6 +47,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -52,7 +56,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.domain.model.ReminderOptions import com.scarriffle.calendarr.domain.model.ReminderOptions
import com.scarriffle.calendarr.domain.model.StagedAttachment
import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.ui.L10n import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.LocalLang
@@ -79,7 +85,8 @@ fun EventEditorSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
defaultDurationMinutes: Int = 60, defaultDurationMinutes: Int = 60,
reminderDisabledKeys: Set<String> = emptySet(), reminderDisabledKeys: Set<String> = emptySet(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>) -> Unit, existingAttachments: List<EventAttachment> = emptyList(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>, List<StagedAttachment>, Set<Int>) -> Unit,
) { ) {
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
val context = LocalContext.current val context = LocalContext.current
@@ -126,6 +133,28 @@ fun EventEditorSheet(
} }
var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) } var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) }
var calMenuOpen by remember { mutableStateOf(false) } var calMenuOpen by remember { mutableStateOf(false) }
// Files picked before the event exists are staged and uploaded after it is
// created; removals of existing ones are applied on save, so Cancel really
// does cancel.
var stagedAttachments by remember { mutableStateOf(emptyList<StagedAttachment>()) }
var removedAttachmentIds by remember { mutableStateOf(emptySet<Int>()) }
val attachmentSlotsUsed =
existingAttachments.count { it.id !in removedAttachmentIds } + stagedAttachments.size
val pickAttachment = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null && attachmentSlotsUsed < 10) {
// Read the bytes now: the content URI may not outlive this screen.
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
val name = queryDisplayName(context, uri) ?: "anhang"
val mime = context.contentResolver.getType(uri)
if (bytes != null && bytes.size <= 10 * 1024 * 1024) {
stagedAttachments = stagedAttachments + StagedAttachment(bytes, name, mime)
}
}
}
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy") val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy")
@@ -282,6 +311,42 @@ fun EventEditorSheet(
}, },
) { Text(tr("event.reminder_add")) } ) { Text(tr("event.reminder_add")) }
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
Text(tr("event.attachments"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
existingAttachments.filter { it.id !in removedAttachmentIds }.forEach { att ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(8.dp))
Text(att.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
androidx.compose.material3.IconButton(onClick = {
removedAttachmentIds = removedAttachmentIds + att.id
}) {
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
}
}
}
stagedAttachments.forEach { file ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.size(8.dp))
Text(file.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
androidx.compose.material3.IconButton(onClick = {
stagedAttachments = stagedAttachments - file
}) {
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
}
}
}
androidx.compose.material3.TextButton(
enabled = attachmentSlotsUsed < 10,
onClick = { pickAttachment.launch(ATTACHMENT_MIME_TYPES) },
) { Text(tr("event.attachment_add")) }
Text(
tr("event.attachment_hint"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(12.dp))
} }
// Color // Color
@@ -325,7 +390,9 @@ fun EventEditorSheet(
end = endDate.atTime(endTime).atZone(zone).toInstant() end = endDate.atTime(endTime).atZone(zone).toInstant()
} }
val rem = if (cal.source == "local") reminders else emptyList() val rem = if (cal.source == "local") reminders else emptyList()
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem) val staged = if (cal.source == "local") stagedAttachments else emptyList()
val removed = if (cal.source == "local") removedAttachmentIds else emptySet()
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem, staged, removed)
}, },
enabled = writableCalendars.isNotEmpty(), enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -405,3 +472,21 @@ private fun ReminderRow(
} }
} }
} }
/** Mirrors the server allowlist. "*/*" is deliberately absent — the picker
* should not offer files the upload would reject. */
private val ATTACHMENT_MIME_TYPES = arrayOf(
"application/pdf",
"image/png", "image/jpeg", "image/webp", "image/gif",
"text/plain", "text/markdown", "text/csv", "text/comma-separated-values",
)
/** The user-visible name behind a content URI; the last path segment is an
* opaque id and would end up as the attachment's filename. */
private fun queryDisplayName(context: android.content.Context, uri: android.net.Uri): String? =
runCatching {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
}
}.getOrNull()