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