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.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<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 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)
}
)
}

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.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<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 ----
@PATCH("api/profile/")

View File

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

View File

@@ -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"
}

View File

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

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