From d10a4dc79ff581b1c308937c4d852719d360f467 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 21:35:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Android=20Datenebene=20f=C3=BCr=20Shari?= =?UTF-8?q?ng/Gruppen/Import-Export/Ersteller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modelle: CalEvent (creator/isPrivate/owner/isGroupEvent/displayColor), LocalCalendar (owned/sharedBy/permission/group), AppSettings (privateEventVisibility/groupVisibleCalendarId), UserProfile (displayName); neue Modelle Group/GroupMember/DirectoryUser/CalendarShareEntry. - API (Retrofit): Profil-Update, Sharing-CRUD, Gruppen-CRUD + combined, Mitglieder-Farbe, iCal Import (multipart)/Export, Kalenderfarbe pro Quelle, gezielte Settings-PUTs (private_visibility/group_visible). - Repository: passende Methoden inkl. private-Flag bei lokalen Events. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/data/CalendarRepository.kt | 168 +++++++++++++++++- .../calendarr/data/remote/CalendarrApi.kt | 62 +++++++ .../calendarr/domain/model/Accounts.kt | 6 + .../calendarr/domain/model/AppSettings.kt | 3 + .../calendarr/domain/model/CalEvent.kt | 22 +++ .../calendarr/domain/model/Groups.kt | 27 +++ 6 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 3cb56e6..59e76cf 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -10,6 +10,10 @@ 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.CalendarShareEntry +import com.scarriffle.calendarr.domain.model.DirectoryUser +import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.domain.model.GroupMember import com.scarriffle.calendarr.domain.model.GoogleAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.ICalSubscription @@ -115,6 +119,7 @@ class CalendarRepository @Inject constructor( "language" to s.language, "month_divider_color" to s.monthDividerColor, "month_label_color" to s.monthLabelColor, + "private_event_visibility" to s.privateEventVisibility, ) ).ensureSuccess() } @@ -265,19 +270,176 @@ class CalendarRepository @Inject constructor( suspend fun createLocalEvent( calendarId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, ) = guarded { - api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color)) + api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate)) .ensureSuccess() } suspend fun updateLocalEvent( uid: String, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, ) = guarded { - api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color)) + api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate)) .ensureSuccess() } + // ---- Sharing ---- + + suspend fun getUserDirectory(): List = guarded { + val resp = api.getUserDirectory() + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(DirectoryUser(o.optInt("id"), o.optString("display_name"))) + } + } + } + + suspend fun getShares(calendarId: Int): List = guarded { + val resp = api.getShares(calendarId) + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(CalendarShareEntry(o.optInt("user_id"), o.optString("display_name"), o.optString("permission"))) + } + } + } + + suspend fun addShare(calendarId: Int, userId: Int, permission: String) = guarded { + api.addShare(calendarId, jsonBody("user_id" to userId, "permission" to permission)).ensureSuccess() + } + + suspend fun removeShare(calendarId: Int, userId: Int) = guarded { + api.removeShare(calendarId, userId).ensureSuccess() + } + + // ---- iCal import/export ---- + + suspend fun importIcs(calendarId: Int, part: okhttp3.MultipartBody.Part): Triple> = guarded { + val resp = api.importCalendar(calendarId, part) + resp.ensureSuccess() + val o = JSONObject(resp.body()?.string() ?: "{}") + val errors = o.optJSONArray("errors") + val errList = buildList { if (errors != null) for (i in 0 until errors.length()) add(errors.optString(i)) } + Triple(o.optInt("imported"), o.optInt("skipped"), errList) + } + + suspend fun exportIcs(calendarId: Int): ByteArray = guarded { + val resp = api.exportCalendar(calendarId) + resp.ensureSuccess() + resp.body()?.bytes() ?: ByteArray(0) + } + + // ---- Groups ---- + + suspend fun getGroups(): List = guarded { + val resp = api.getGroups() + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(Group( + id = o.optInt("id"), + name = o.optString("name"), + icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null }, + role = o.optString("role", "member"), + memberCount = o.optInt("member_count", 0), + groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"), + groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null }, + members = emptyList(), + )) + } + } + } + + suspend fun getGroup(id: Int): Group = guarded { + val resp = api.getGroup(id) + resp.ensureSuccess() + val o = JSONObject(resp.body()?.string() ?: "{}") + val membersArr = o.optJSONArray("members") + val members = buildList { + if (membersArr != null) for (i in 0 until membersArr.length()) { + val m = membersArr.optJSONObject(i) ?: continue + add(GroupMember( + m.optInt("id"), m.optString("display_name"), m.optString("role", "member"), + if (m.isNull("color")) null else m.optString("color").ifBlank { null }, + )) + } + } + Group( + id = o.optInt("id"), + name = o.optString("name"), + icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null }, + role = "", + memberCount = members.size, + groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"), + groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null }, + members = members, + ) + } + + suspend fun createGroup(name: String, memberIds: List, icon: String?) = guarded { + val arr = org.json.JSONArray() + memberIds.forEach { arr.put(it) } + api.createGroup(jsonBody("name" to name, "member_ids" to arr, "icon" to icon)).ensureSuccess() + } + + suspend fun updateGroup(id: Int, name: String?, icon: String?) = guarded { + api.updateGroup(id, jsonBody("name" to name, "icon" to icon)).ensureSuccess() + } + + suspend fun addGroupMember(groupId: Int, userId: Int) = guarded { + api.addGroupMember(groupId, jsonBody("user_id" to userId)).ensureSuccess() + } + + suspend fun removeGroupMember(groupId: Int, userId: Int) = guarded { + api.removeGroupMember(groupId, userId).ensureSuccess() + } + + suspend fun setGroupMemberColor(groupId: Int, userId: Int, color: String) = guarded { + api.setGroupMemberColor(groupId, userId, jsonBody("color" to color)).ensureSuccess() + } + + suspend fun deleteGroup(id: Int) = guarded { api.deleteGroup(id).ensureSuccess() } + + // ---- Profile & targeted settings ---- + + suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded { + val resp = api.updateProfile(jsonBody("display_name" to displayName, "username" to username, "email" to email)) + resp.ensureSuccess() + runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull() + } + + suspend fun updatePrivateVisibility(value: String) = guarded { + api.updateSettings(jsonBody("private_event_visibility" to value)).ensureSuccess() + } + + suspend fun updateGroupVisibleCalendar(calendarId: Int?) = guarded { + // Send explicit JSON null to clear (jsonBody drops Kotlin nulls). + api.updateSettings(jsonBody("group_visible_calendar_id" to (calendarId ?: org.json.JSONObject.NULL))) + .ensureSuccess() + } + + suspend fun fetchGroupCombined(groupId: Int, start: Instant, end: Instant): List = withContext(Dispatchers.IO) { + val resp = api.fetchGroupCombined(groupId, Dates.isoUtc(start), Dates.isoUtc(end)) + resp.ensureSuccess() + val root = JSONObject(resp.body()?.string() ?: "{}") + val arr = root.optJSONArray("events") ?: return@withContext emptyList() + buildList { + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + CalEvent.fromJson(obj)?.let { add(it) } + } + } + } + suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() } suspend fun createCalDAVEvent( @@ -358,6 +520,7 @@ class CalendarRepository @Inject constructor( private fun eventBody( calendarId: Int?, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, ) = jsonBody( buildMap { calendarId?.let { put("calendar_id", it) } @@ -368,6 +531,7 @@ class CalendarRepository @Inject constructor( put("location", location) put("description", description) if (!color.isNullOrBlank()) put("color", color) + put("private", isPrivate) } ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index d831a0d..0927da0 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -7,6 +7,7 @@ import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.LocalCalendar import com.scarriffle.calendarr.domain.model.UserProfile +import okhttp3.MultipartBody import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Response @@ -14,9 +15,11 @@ import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.HTTP +import retrofit2.http.Multipart import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -48,6 +51,65 @@ interface CalendarrApi { @PUT("api/settings/") suspend fun updateSettings(@Body body: RequestBody): Response + @PUT("api/profile/") + suspend fun updateProfile(@Body body: RequestBody): Response + + // ---- Sharing ---- + + @GET("api/users/directory") + suspend fun getUserDirectory(): Response + + @GET("api/local/calendars/{id}/shares") + suspend fun getShares(@Path("id") id: Int): Response + + @POST("api/local/calendars/{id}/shares") + suspend fun addShare(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/local/calendars/{id}/shares/{userId}") + suspend fun removeShare(@Path("id") id: Int, @Path("userId") userId: Int): Response + + // ---- iCal import/export ---- + + @Multipart + @POST("api/local/calendars/{id}/import") + suspend fun importCalendar(@Path("id") id: Int, @Part part: MultipartBody.Part): Response + + @GET("api/local/calendars/{id}/export") + suspend fun exportCalendar(@Path("id") id: Int): Response + + // ---- Groups ---- + + @GET("api/groups/") + suspend fun getGroups(): Response + + @GET("api/groups/{id}") + suspend fun getGroup(@Path("id") id: Int): Response + + @POST("api/groups/") + suspend fun createGroup(@Body body: RequestBody): Response + + @PUT("api/groups/{id}") + suspend fun updateGroup(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/groups/{id}") + suspend fun deleteGroup(@Path("id") id: Int): Response + + @POST("api/groups/{id}/members") + suspend fun addGroupMember(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/groups/{id}/members/{userId}") + suspend fun removeGroupMember(@Path("id") id: Int, @Path("userId") userId: Int): Response + + @PUT("api/groups/{id}/members/{userId}/color") + suspend fun setGroupMemberColor(@Path("id") id: Int, @Path("userId") userId: Int, @Body body: RequestBody): Response + + @GET("api/groups/{id}/combined") + suspend fun fetchGroupCombined( + @Path("id") id: Int, + @Query("start") start: String, + @Query("end") end: String, + ): Response + // ---- Profile ---- @PATCH("api/profile/") diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt index aa41878..07ad36f 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -29,6 +29,11 @@ data class LocalCalendar( val name: String = "", val color: String = "#34a853", val enabled: Boolean = true, + val type: String = "local", + val owned: Boolean = true, + @Json(name = "shared_by") val sharedBy: String? = null, + val permission: String? = null, + val group: Boolean = false, ) @JsonClass(generateAdapter = false) @@ -81,6 +86,7 @@ data class HACalendar( data class UserProfile( val id: Int, val username: String = "", + @Json(name = "display_name") val displayName: String? = null, val email: String? = null, @Json(name = "is_admin") val isAdmin: Boolean = false, @Json(name = "has_avatar") val hasAvatar: Boolean = false, diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt index 2e5f5d0..7f6aad5 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -22,6 +22,9 @@ data class AppSettings( @Json(name = "language") val language: String = "de", @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", @Json(name = "month_label_color") val monthLabelColor: String = "#7090c0", + // How this user's private events appear to other group members: 'hidden' | 'busy'. + @Json(name = "private_event_visibility") val privateEventVisibility: String = "busy", + @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, ) { val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index 5a3e0b2..77d9ccb 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -8,6 +8,9 @@ import java.time.Instant * A unified calendar event, blended from all server sources * (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`. */ +/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */ +data class EventPerson(val id: Int?, val displayName: String) + data class CalEvent( val id: String, val url: String, @@ -22,6 +25,11 @@ data class CalEvent( val calendarName: String, val calendarColor: String, val source: String, + val creator: EventPerson? = null, + val isPrivate: Boolean = false, + // Only set in the group combined view: + val owner: EventPerson? = null, + val isGroupEvent: Boolean = false, ) { /** * Per-event override colour, then the calendar's colour, then a stable @@ -54,6 +62,16 @@ data class CalEvent( 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 + val name = obj.strOrNull("display_name") ?: return null + val id = if (obj.isNull("id")) null else obj.opt("id")?.let { + when (it) { is Number -> it.toInt(); is String -> it.toIntOrNull(); else -> null } + } + return EventPerson(id, name) + } + /** Parse one event object from the `/api/caldav/events` aggregate response. */ fun fromJson(json: JSONObject): CalEvent? { val title = json.strOrNull("title") ?: return null @@ -91,6 +109,10 @@ data class CalEvent( calendarName = json.strOrNull("calendar_name") ?: "", calendarColor = json.strOrNull("calendarColor") ?: "", source = json.strOrNull("source") ?: "local", + creator = personFrom(json, "creator"), + isPrivate = json.optBoolean("private", false), + owner = personFrom(json, "owner"), + isGroupEvent = json.optBoolean("is_group_event", false), ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt new file mode 100644 index 0000000..a0468b0 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt @@ -0,0 +1,27 @@ +package com.scarriffle.calendarr.domain.model + +/** Lightweight user from /api/users/directory (for sharing/group pickers). */ +data class DirectoryUser(val id: Int, val displayName: String) + +/** A share entry on a local calendar. */ +data class CalendarShareEntry(val userId: Int, val displayName: String, val permission: String) + +/** A member of a group, with their server-defined colour. */ +data class GroupMember( + val id: Int, + val displayName: String, + val role: String, + val color: String? = null, +) + +/** A user group + its shared group calendar. */ +data class Group( + val id: Int, + val name: String, + val icon: String? = null, + val role: String, + val memberCount: Int, + val groupCalendarId: Int?, + val groupCalendarColor: String? = null, + val members: List, +)