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:
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
Reference in New Issue
Block a user