From a1f15e33aacc0ebe4b8fe52ffbb205de532f2345 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 14 Sep 2026 10:39:16 +0200 Subject: [PATCH] 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) --- .../calendarr/data/CalendarRepository.kt | 56 +++++++++++- .../calendarr/data/remote/CalendarrApi.kt | 23 +++++ .../calendarr/domain/model/CalEvent.kt | 87 +++++++++++++++--- .../java/com/scarriffle/calendarr/ui/L10n.kt | 4 + .../calendarr/ui/calendar/CalendarScreen.kt | 37 +++++++- .../ui/calendar/CalendarViewModel.kt | 78 +++++++++++++++- .../calendarr/ui/event/EventDetailScreen.kt | 71 +++++++++++++++ .../calendarr/ui/event/EventEditorSheet.kt | 89 ++++++++++++++++++- 8 files changed, 423 insertions(+), 22 deletions(-) diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 32b1e01..f86ce54 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -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? = 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 = 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() diff --git a/android/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index 3b4d75d..e841433 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -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 + // ---- 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 + + @Multipart + @POST("api/local/events/{uid}/attachments") + suspend fun uploadAttachment(@Path("uid") uid: String, + @Part part: MultipartBody.Part): Response + + @Streaming + @GET("api/local/attachments/{id}") + suspend fun downloadAttachment(@Path("id") id: Int): Response + + @GET("api/local/attachments/{id}/thumb") + suspend fun attachmentThumbnail(@Path("id") id: Int): Response + + @DELETE("api/local/attachments/{id}") + suspend fun deleteAttachment(@Path("id") id: Int): Response + // ---- Groups ---- @GET("api/groups/") diff --git a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index 65c3e61..58924c8 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -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), ) } } diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 2436794..4b98fee 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -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", diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index eb6560b..547419a 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -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(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(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(null) } var overlay by remember { mutableStateOf(Overlay.NONE) } var dayPreview by remember { mutableStateOf(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 } }, diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index f479968..f888931 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -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 = emptyList(), + stagedAttachments: List = emptyList(), + removedAttachmentIds: Set = 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 = emptyList(), + stagedAttachments: List = 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>(emptyList()) + val attachments: StateFlow> = _attachments.asStateFlow() + + private val _attachmentThumbs = MutableStateFlow>(emptyMap()) + val attachmentThumbs: StateFlow> = _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() + 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, + removedIds: Set, + ) { + 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 { diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt index 893cb78..5845ce8 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt @@ -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 = emptyList(), + attachmentThumbs: Map = 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)) +} diff --git a/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt index 73cf958..734f52b 100644 --- a/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt +++ b/android/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -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 = emptySet(), - onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List) -> Unit, + existingAttachments: List = emptyList(), + onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List, List, Set) -> 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()) } + var removedAttachmentIds by remember { mutableStateOf(emptySet()) } + 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(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()