feat: Android Datenebene für Sharing/Gruppen/Import-Export/Ersteller

- 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 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 21:35:54 +02:00
parent 9467fe7bb6
commit d10a4dc79f
6 changed files with 286 additions and 2 deletions

View File

@@ -10,6 +10,10 @@ import com.scarriffle.calendarr.data.remote.jsonBody
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.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.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.ICalSubscription
@@ -115,6 +119,7 @@ class CalendarRepository @Inject constructor(
"language" to s.language, "language" to s.language,
"month_divider_color" to s.monthDividerColor, "month_divider_color" to s.monthDividerColor,
"month_label_color" to s.monthLabelColor, "month_label_color" to s.monthLabelColor,
"private_event_visibility" to s.privateEventVisibility,
) )
).ensureSuccess() ).ensureSuccess()
} }
@@ -265,19 +270,176 @@ class CalendarRepository @Inject constructor(
suspend fun createLocalEvent( suspend fun createLocalEvent(
calendarId: Int, title: String, start: Instant, end: Instant, calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false,
) = guarded { ) = 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() .ensureSuccess()
} }
suspend fun updateLocalEvent( suspend fun updateLocalEvent(
uid: String, title: String, start: Instant, end: Instant, uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false,
) = guarded { ) = 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() .ensureSuccess()
} }
// ---- Sharing ----
suspend fun getUserDirectory(): List<DirectoryUser> = 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<CalendarShareEntry> = 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<Int, Int, List<String>> = guarded {
val resp = api.importCalendar(calendarId, part)
resp.ensureSuccess()
val o = JSONObject(resp.body()?.string() ?: "{}")
val errors = o.optJSONArray("errors")
val errList = buildList<String> { 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<Group> = 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<GroupMember> {
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<Int>, 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<CalEvent> = 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 deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() }
suspend fun createCalDAVEvent( suspend fun createCalDAVEvent(
@@ -358,6 +520,7 @@ class CalendarRepository @Inject constructor(
private fun eventBody( private fun eventBody(
calendarId: Int?, title: String, start: Instant, end: Instant, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false,
) = jsonBody( ) = jsonBody(
buildMap { buildMap {
calendarId?.let { put("calendar_id", it) } calendarId?.let { put("calendar_id", it) }
@@ -368,6 +531,7 @@ class CalendarRepository @Inject constructor(
put("location", location) put("location", location)
put("description", description) put("description", description)
if (!color.isNullOrBlank()) put("color", color) if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate)
} }
) )
} }

View File

@@ -7,6 +7,7 @@ import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.UserProfile import com.scarriffle.calendarr.domain.model.UserProfile
import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
@@ -14,9 +15,11 @@ import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.Multipart
import retrofit2.http.PATCH import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@@ -48,6 +51,65 @@ interface CalendarrApi {
@PUT("api/settings/") @PUT("api/settings/")
suspend fun updateSettings(@Body body: RequestBody): Response<ResponseBody> suspend fun updateSettings(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/profile/")
suspend fun updateProfile(@Body body: RequestBody): Response<ResponseBody>
// ---- Sharing ----
@GET("api/users/directory")
suspend fun getUserDirectory(): Response<ResponseBody>
@GET("api/local/calendars/{id}/shares")
suspend fun getShares(@Path("id") id: Int): Response<ResponseBody>
@POST("api/local/calendars/{id}/shares")
suspend fun addShare(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}/shares/{userId}")
suspend fun removeShare(@Path("id") id: Int, @Path("userId") userId: Int): Response<ResponseBody>
// ---- iCal import/export ----
@Multipart
@POST("api/local/calendars/{id}/import")
suspend fun importCalendar(@Path("id") id: Int, @Part part: MultipartBody.Part): Response<ResponseBody>
@GET("api/local/calendars/{id}/export")
suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody>
// ---- Groups ----
@GET("api/groups/")
suspend fun getGroups(): Response<ResponseBody>
@GET("api/groups/{id}")
suspend fun getGroup(@Path("id") id: Int): Response<ResponseBody>
@POST("api/groups/")
suspend fun createGroup(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/groups/{id}")
suspend fun updateGroup(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/groups/{id}")
suspend fun deleteGroup(@Path("id") id: Int): Response<ResponseBody>
@POST("api/groups/{id}/members")
suspend fun addGroupMember(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/groups/{id}/members/{userId}")
suspend fun removeGroupMember(@Path("id") id: Int, @Path("userId") userId: Int): Response<ResponseBody>
@PUT("api/groups/{id}/members/{userId}/color")
suspend fun setGroupMemberColor(@Path("id") id: Int, @Path("userId") userId: Int, @Body body: RequestBody): Response<ResponseBody>
@GET("api/groups/{id}/combined")
suspend fun fetchGroupCombined(
@Path("id") id: Int,
@Query("start") start: String,
@Query("end") end: String,
): Response<ResponseBody>
// ---- Profile ---- // ---- Profile ----
@PATCH("api/profile/") @PATCH("api/profile/")

View File

@@ -29,6 +29,11 @@ data class LocalCalendar(
val name: String = "", val name: String = "",
val color: String = "#34a853", val color: String = "#34a853",
val enabled: Boolean = true, 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) @JsonClass(generateAdapter = false)
@@ -81,6 +86,7 @@ data class HACalendar(
data class UserProfile( data class UserProfile(
val id: Int, val id: Int,
val username: String = "", val username: String = "",
@Json(name = "display_name") val displayName: String? = null,
val email: String? = null, val email: String? = null,
@Json(name = "is_admin") val isAdmin: Boolean = false, @Json(name = "is_admin") val isAdmin: Boolean = false,
@Json(name = "has_avatar") val hasAvatar: Boolean = false, @Json(name = "has_avatar") val hasAvatar: Boolean = false,

View File

@@ -22,6 +22,9 @@ data class AppSettings(
@Json(name = "language") val language: String = "de", @Json(name = "language") val language: String = "de",
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
@Json(name = "month_label_color") val monthLabelColor: 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" val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
} }

View File

@@ -8,6 +8,9 @@ import java.time.Instant
* A unified calendar event, blended from all server sources * A unified calendar event, blended from all server sources
* (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`. * (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( data class CalEvent(
val id: String, val id: String,
val url: String, val url: String,
@@ -22,6 +25,11 @@ data class CalEvent(
val calendarName: String, val calendarName: String,
val calendarColor: String, val calendarColor: String,
val source: 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 * 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" } 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. */ /** Parse one event object from the `/api/caldav/events` aggregate response. */
fun fromJson(json: JSONObject): CalEvent? { fun fromJson(json: JSONObject): CalEvent? {
val title = json.strOrNull("title") ?: return null val title = json.strOrNull("title") ?: return null
@@ -91,6 +109,10 @@ data class CalEvent(
calendarName = json.strOrNull("calendar_name") ?: "", calendarName = json.strOrNull("calendar_name") ?: "",
calendarColor = json.strOrNull("calendarColor") ?: "", calendarColor = json.strOrNull("calendarColor") ?: "",
source = json.strOrNull("source") ?: "local", 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),
) )
} }
} }

View File

@@ -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<GroupMember>,
)