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()