Compare commits

...

4 Commits

Author SHA1 Message Date
Guido Schmit
e0d1b24afc feat: Android Kalenderfarben, Sharing & iCal-Import/Export (lokale Kalender)
AccountsScreen überarbeitet: editierbare Farb-Punkte (lokal, iCal, sowie
Unterkalender von CalDAV/Google/HA via Farbwähler-Dialog); lokale Kalender
zeigen Gruppen-Marker + "geteilt von" und ein Aktionsmenü (Teilen/Importieren/
Exportieren/Löschen je nach Besitz & Berechtigung). Teilen-Sheet mit
Benutzersuche + Berechtigung; Import via OpenDocument, Export via CreateDocument.
Repository: importIcsFile(bytes) + Farb-Update-Methoden; ViewModel um Farb-,
Sharing- und Import/Export-Logik erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:17:53 +02:00
Guido Schmit
716dd01abb feat: Android Profil-Kapitel in Einstellungen + Kalenderfarb-Endpunkte
Einstellungen zeigen jetzt ein Profil-Kapitel: Anzeigename (editierbar),
Anmeldename (read-only), E-Mail + Speichern; Privatsphäre (private Termine
beschäftigt/verbergen) und geteilter Kalender (Dropdown der eigenen lokalen
Kalender) — serverseitig geladen und gezielt gespeichert.
Datenebene um Farb-Endpunkte ergänzt (lokal/iCal PUT + setCalendarColor für
caldav/google/ha) als Basis für die Farbbearbeitung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:07:06 +02:00
Guido Schmit
4a44c20b12 feat: Android Ersteller-Anzeige + Privat-Flag
Event-Detail zeigt "Erstellt von" (wenn != ich) + Privat-Hinweis; Editor hat
Privat-Toggle (nur lokale Kalender, durch saveEvent/Repo durchgereicht).
Login speichert userId + displayName (CredentialStore) für Vergleiche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:54:19 +02:00
Guido Schmit
d10a4dc79f 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>
2026-05-31 21:35:54 +02:00
16 changed files with 921 additions and 25 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
@@ -19,6 +23,8 @@ import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import retrofit2.HttpException
import java.time.Instant
@@ -42,6 +48,9 @@ class CalendarRepository @Inject constructor(
) {
private val api get() = apiProvider.api()
/** Current logged-in user id (0 if unknown) — for creator/owner comparisons. */
val currentUserId: Int get() = credentialStore.userId
private suspend fun <T> guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) {
try {
block()
@@ -89,9 +98,11 @@ class CalendarRepository @Inject constructor(
val user = json.optJSONObject("user")
val uname = user?.optString("username") ?: username
val isAdmin = user?.optBoolean("is_admin", false) ?: false
val uid = user?.optInt("id", 0) ?: 0
val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname
credentialStore.serverUrl = ApiProvider.normalize(baseUrl)
credentialStore.saveLogin(token, uname, isAdmin)
credentialStore.saveLogin(token, uname, isAdmin, uid, displayName)
apiProvider.invalidate()
LoginResult(token, uname, isAdmin)
}
@@ -115,6 +126,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()
}
@@ -200,6 +212,27 @@ class CalendarRepository @Inject constructor(
suspend fun deleteHomeAssistantAccount(id: Int) =
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
/** Change a local calendar's colour. */
suspend fun updateLocalCalendarColor(id: Int, color: String) = guarded {
api.updateLocalCalendar(id, jsonBody("color" to color)).ensureSuccess()
}
/** Change an iCal subscription's colour. */
suspend fun updateICalColor(id: Int, color: String) = guarded {
api.updateICalSubscription(id, jsonBody("color" to color)).ensureSuccess()
}
/** Set a per-calendar colour for server-managed sources (caldav/google/homeassistant). */
suspend fun setCalendarColor(source: String, calendarId: Int, color: String) = guarded {
val body = jsonBody("color" to color)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
else -> Unit
}
}
/** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */
suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded {
val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden)
@@ -265,19 +298,183 @@ 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)
}
/** Build the multipart body from raw bytes and import (server form field: "file"). */
suspend fun importIcsFile(calendarId: Int, bytes: ByteArray, filename: String): Triple<Int, Int, List<String>> {
val body = bytes.toRequestBody("text/calendar".toMediaTypeOrNull())
val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body)
return importIcs(calendarId, part)
}
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 +555,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 +566,7 @@ class CalendarRepository @Inject constructor(
put("location", location)
put("description", description)
if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate)
}
)
}

View File

@@ -46,17 +46,27 @@ class CredentialStore @Inject constructor(
get() = prefs.getBoolean(KEY_IS_ADMIN, false)
set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply()
var userId: Int
get() = prefs.getInt(KEY_USER_ID, 0)
set(value) = prefs.edit().putInt(KEY_USER_ID, value).apply()
var displayName: String?
get() = prefs.getString(KEY_DISPLAY_NAME, null)
set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply()
/** True once a server URL has been entered (setup step complete). */
val isConfigured: Boolean get() = !serverUrl.isNullOrBlank()
/** True once we hold an auth token (logged in). */
val isLoggedIn: Boolean get() = !token.isNullOrBlank()
fun saveLogin(token: String, username: String, isAdmin: Boolean) {
fun saveLogin(token: String, username: String, isAdmin: Boolean, userId: Int = 0, displayName: String? = null) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.putBoolean(KEY_IS_ADMIN, isAdmin)
.putInt(KEY_USER_ID, userId)
.putString(KEY_DISPLAY_NAME, displayName ?: username)
.apply()
}
@@ -79,5 +89,7 @@ class CredentialStore @Inject constructor(
const val KEY_TOKEN = "auth_token"
const val KEY_USERNAME = "username"
const val KEY_IS_ADMIN = "is_admin"
const val KEY_USER_ID = "user_id"
const val KEY_DISPLAY_NAME = "display_name"
}
}

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/")
@@ -90,6 +152,9 @@ interface CalendarrApi {
@POST("api/local/calendars")
suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar
@PUT("api/local/calendars/{id}")
suspend fun updateLocalCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}")
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
@@ -101,6 +166,9 @@ interface CalendarrApi {
@POST("api/ical/subscriptions")
suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription
@PUT("api/ical/subscriptions/{id}")
suspend fun updateICalSubscription(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/ical/subscriptions/{id}")
suspend fun deleteICalSubscription(@Path("id") id: Int): Response<ResponseBody>

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,13 +25,21 @@ 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,
val displayColor: String? = null,
) {
/**
* Per-event override colour, then the calendar's colour, then a stable
* Group view supplies a server-resolved colour (display_color); otherwise
* per-event override colour, then the calendar's colour, then a stable
* per-calendar palette colour (so events never collapse to one default).
*/
val effectiveColor: String
get() = color?.takeIf { it.isNotBlank() }
get() = displayColor?.takeIf { it.isNotBlank() }
?: color?.takeIf { it.isNotBlank() }
?: calendarColor.takeIf { it.isNotBlank() }
?: fallbackColorFor("$source:$calendarId")
@@ -54,6 +65,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 +112,11 @@ 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),
displayColor = json.strOrNull("display_color"),
)
}
}

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

View File

@@ -87,7 +87,7 @@ object L10n {
"twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren",
"twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort",
"twofa.disable" to "Deaktivieren",
"event.title_placeholder" to "Titel", "event.allday" to "Ganztägig",
"event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", "event.private" to "Privat", "event.created_by" to "Erstellt von",
"event.start" to "Start", "event.end" to "Ende", "event.location" to "Ort",
"event.description" to "Beschreibung", "event.calendar_section" to "Kalender",
"event.no_writable" to "Keine beschreibbaren Kalender vorhanden",
@@ -122,6 +122,32 @@ object L10n {
"ical.refresh.1h" to "Stündlich", "ical.refresh.6h" to "Alle 6 Std.", "ical.refresh.1d" to "Täglich",
"ha.display_name" to "Anzeigename", "ha.url_placeholder" to "URL (z.B. http://homeassistant.local:8123)",
"ha.token" to "Long-Lived Access Token", "ha.connect" to "Verbinden",
// Collaboration: profile / privacy / sharing / groups / import-export
"settings.nav.profile" to "Profil",
"profile.display_name" to "Anzeigename", "profile.login_name" to "Anmeldename",
"settings.privacy" to "Privatsphäre", "settings.private_visibility" to "Private Termine",
"settings.private.busy" to "Als beschäftigt zeigen", "settings.private.hidden" to "Verbergen",
"settings.private_visibility.desc" to "Legt fest, wie deine privaten Termine anderen Gruppenmitgliedern erscheinen.",
"settings.group_visible" to "Geteilter Kalender",
"settings.group_visible.desc" to "Dieser Kalender wird in der Gruppenansicht für andere sichtbar.",
"group.visible.none" to "Keiner",
"share.title" to "Teilen", "share.with" to "Geteilt mit", "share.add" to "Person hinzufügen",
"share.permission.read" to "Lesen", "share.permission.read_write" to "Bearbeiten",
"share.none" to "Noch nicht geteilt", "share.search" to "Benutzer suchen",
"accounts.shared.header" to "Kalender von anderen", "accounts.shared_by" to "geteilt von %@",
"accounts.color" to "Farbe ändern", "accounts.action.share" to "Teilen",
"accounts.action.import" to "Importieren", "accounts.action.export" to "Exportieren",
"import.title" to "iCal importieren", "import.result" to "%d importiert, %d übersprungen",
"import.failed" to "Import fehlgeschlagen", "export.failed" to "Export fehlgeschlagen",
"menu.groups" to "Gruppen", "groups.title" to "Gruppen", "groups.empty" to "Noch keine Gruppen",
"groups.create" to "Gruppe erstellen", "groups.name" to "Gruppenname",
"groups.members" to "Mitglieder", "groups.member_count" to "%d Mitglieder",
"groups.icon" to "Symbol", "groups.manage" to "Verwalten",
"groups.delete" to "Gruppe löschen", "groups.delete_confirm" to "Diese Gruppe löschen?",
"groups.leave" to "Gruppe verlassen", "groups.member_color" to "Farbe",
"groups.add_member" to "Mitglied hinzufügen", "groups.view" to "Gruppenansicht",
"groups.calendar" to "Gruppenkalender", "group.event" to "Gruppentermin",
"group.switch.personal" to "Mein Kalender",
// Auth screens
"auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL",
"auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.",
@@ -188,7 +214,7 @@ object L10n {
"twofa.code_placeholder" to "6-digit code", "twofa.activate" to "Activate",
"twofa.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password",
"twofa.disable" to "Disable",
"event.title_placeholder" to "Title", "event.allday" to "All-day",
"event.title_placeholder" to "Title", "event.allday" to "All-day", "event.private" to "Private", "event.created_by" to "Created by",
"event.start" to "Start", "event.end" to "End", "event.location" to "Location",
"event.description" to "Description", "event.calendar_section" to "Calendar",
"event.no_writable" to "No writable calendars available",
@@ -223,6 +249,32 @@ object L10n {
"ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily",
"ha.display_name" to "Display name", "ha.url_placeholder" to "URL (e.g. http://homeassistant.local:8123)",
"ha.token" to "Long-Lived Access Token", "ha.connect" to "Connect",
// Collaboration: profile / privacy / sharing / groups / import-export
"settings.nav.profile" to "Profile",
"profile.display_name" to "Display name", "profile.login_name" to "Login name",
"settings.privacy" to "Privacy", "settings.private_visibility" to "Private events",
"settings.private.busy" to "Show as busy", "settings.private.hidden" to "Hidden",
"settings.private_visibility.desc" to "Controls how your private events appear to other group members.",
"settings.group_visible" to "Shared calendar",
"settings.group_visible.desc" to "This calendar is shown to others in the group view.",
"group.visible.none" to "None",
"share.title" to "Share", "share.with" to "Shared with", "share.add" to "Add person",
"share.permission.read" to "Read", "share.permission.read_write" to "Read & write",
"share.none" to "Not shared yet", "share.search" to "Search users",
"accounts.shared.header" to "Calendars from others", "accounts.shared_by" to "shared by %@",
"accounts.color" to "Change color", "accounts.action.share" to "Share",
"accounts.action.import" to "Import", "accounts.action.export" to "Export",
"import.title" to "Import iCal", "import.result" to "%d imported, %d skipped",
"import.failed" to "Import failed", "export.failed" to "Export failed",
"menu.groups" to "Groups", "groups.title" to "Groups", "groups.empty" to "No groups yet",
"groups.create" to "Create group", "groups.name" to "Group name",
"groups.members" to "Members", "groups.member_count" to "%d members",
"groups.icon" to "Icon", "groups.manage" to "Manage",
"groups.delete" to "Delete group", "groups.delete_confirm" to "Delete this group?",
"groups.leave" to "Leave group", "groups.member_color" to "Color",
"groups.add_member" to "Add member", "groups.view" to "Group view",
"groups.calendar" to "Group calendar", "group.event" to "Group event",
"group.switch.personal" to "My calendar",
// Auth screens
"auth.server_title" to "Connect server", "auth.server_url" to "Server URL",
"auth.server_hint" to "Enter the address of your Calendarr server.",

View File

@@ -1,5 +1,7 @@
package com.scarriffle.calendarr.ui.accounts
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -13,23 +15,33 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -37,15 +49,33 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.components.PasswordField
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
private enum class AddType { LOCAL, CALDAV, ICAL, HA }
/** What colour the picker dialog is currently editing. */
private sealed interface ColorTarget {
val current: String
data class Local(val id: Int, override val current: String) : ColorTarget
data class ICal(val id: Int, override val current: String) : ColorTarget
data class Source(val source: String, val id: Int, override val current: String) : ColorTarget
}
private fun safeFileName(name: String): String {
val cleaned = name.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")
return (cleaned.ifBlank { "calendar" }) + ".ics"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(
@@ -53,7 +83,38 @@ fun AccountsScreen(
onChanged: () -> Unit,
vm: AccountsViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val lang = LocalLang.current
var addDialog by remember { mutableStateOf<AddType?>(null) }
var editingColor by remember { mutableStateOf<ColorTarget?>(null) }
var shareCalId by remember { mutableStateOf<Int?>(null) }
// Import: pick a document, read its bytes, upload to importTarget.
var importTarget by remember { mutableStateOf<Int?>(null) }
val openDoc = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
val calId = importTarget
importTarget = null
if (uri != null && calId != null) {
val bytes = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
if (bytes != null) {
vm.importIcs(
calId, bytes, "import.ics",
result = { imported, skipped -> L10n.t("import.result", lang, imported, skipped) },
onChanged = onChanged,
)
}
}
}
// Export: fetch bytes first, then let the user save them to a chosen location.
var pendingExport by remember { mutableStateOf<ByteArray?>(null) }
val createDoc = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/calendar")) { uri ->
val bytes = pendingExport
pendingExport = null
if (uri != null && bytes != null) {
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } }
}
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
@@ -71,39 +132,69 @@ fun AccountsScreen(
return@Scaffold
}
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
// Local
// Local (incl. shared-with-me and group calendars)
item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } }
if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) }
items(vm.local, key = { "l${it.id}" }) { cal ->
AccountRow(cal.name, cal.color) { vm.deleteLocal(cal.id, onChanged) }
LocalCalendarRow(
cal = cal,
onColor = { editingColor = ColorTarget.Local(cal.id, cal.color) },
onShare = { shareCalId = cal.id },
onImport = { importTarget = cal.id; openDoc.launch(arrayOf("*/*")) },
onExport = { vm.exportIcs(cal.id) { bytes -> pendingExport = bytes; createDoc.launch(safeFileName(cal.name)) } },
onDelete = { vm.deleteLocal(cal.id, onChanged) },
)
}
// CalDAV
item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } }
if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) }
items(vm.caldav, key = { "c${it.id}" }) { acc ->
AccountRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) }
Column {
AccountHeaderRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: acc.color) {
editingColor = ColorTarget.Source("caldav", cal.id, cal.color ?: acc.color)
}
}
}
}
// iCal
item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } }
if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) }
items(vm.ical, key = { "i${it.id}" }) { sub ->
AccountRow(sub.name, sub.color) { vm.deleteICal(sub.id, onChanged) }
EditableColorRow(sub.name, sub.color, onColor = { editingColor = ColorTarget.ICal(sub.id, sub.color) }) {
vm.deleteICal(sub.id, onChanged)
}
}
// Google
item { SectionHeaderNoAdd(tr("accounts.google.header")) }
if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) }
items(vm.google, key = { "g${it.id}" }) { acc ->
AccountRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) }
Column {
AccountHeaderRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: "#4285f4") {
editingColor = ColorTarget.Source("google", cal.id, cal.color ?: "#4285f4")
}
}
}
}
// Home Assistant
item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } }
if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) }
items(vm.homeAssistant, key = { "h${it.id}" }) { acc ->
AccountRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) }
Column {
AccountHeaderRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: "#46bdc6") {
editingColor = ColorTarget.Source("homeassistant", cal.id, cal.color ?: "#46bdc6")
}
}
}
}
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
@@ -127,6 +218,34 @@ fun AccountsScreen(
}
null -> Unit
}
editingColor?.let { target ->
ColorPickerDialog(
initial = target.current,
title = tr("accounts.color"),
onDismiss = { editingColor = null },
onConfirm = { hex ->
when (target) {
is ColorTarget.Local -> vm.setLocalColor(target.id, hex, onChanged)
is ColorTarget.ICal -> vm.setICalColor(target.id, hex, onChanged)
is ColorTarget.Source -> vm.setSourceColor(target.source, target.id, hex, onChanged)
}
editingColor = null
},
)
}
shareCalId?.let { id ->
SharingSheet(vm = vm, calendarId = id, onDismiss = { shareCalId = null })
}
vm.infoMessage?.let { msg ->
AlertDialog(
onDismissRequest = { vm.clearInfo() },
confirmButton = { TextButton(onClick = { vm.clearInfo() }) { Text(tr("common.ok")) } },
text = { Text(msg) },
)
}
}
@Composable
@@ -155,13 +274,45 @@ private fun EmptyRow(text: String) {
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp))
}
/** Tappable colour swatch. */
@Composable
private fun AccountRow(name: String, color: String, onDelete: () -> Unit) {
private fun ColorDot(hex: String, editable: Boolean, onClick: () -> Unit) {
val base = Modifier.size(if (editable) 18.dp else 14.dp).clip(CircleShape).background(colorFromHex(hex))
Box(if (editable) base.clickable(onClick = onClick) else base)
}
/** Account header (CalDAV/Google/HA) with a delete button; colour edited on the child rows. */
@Composable
private fun AccountHeaderRow(name: String, color: String, onDelete: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp),
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color)))
Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
}
}
}
/** A child calendar of a server-managed account: editable colour + name. */
@Composable
private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ColorDot(color, editable = true, onClick = onColor)
Text(name, modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
/** A simple row (iCal) with editable colour + delete. */
@Composable
private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
ColorDot(color, editable = true, onClick = onColor)
Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
@@ -169,6 +320,120 @@ private fun AccountRow(name: String, color: String, onDelete: () -> Unit) {
}
}
/** Local calendar row: colour, group marker, "shared by", and an actions menu. */
@Composable
private fun LocalCalendarRow(
cal: LocalCalendar,
onColor: () -> Unit,
onShare: () -> Unit,
onImport: () -> Unit,
onExport: () -> Unit,
onDelete: () -> Unit,
) {
var menu by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
ColorDot(cal.color, editable = cal.owned, onClick = onColor)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(cal.name, style = MaterialTheme.typography.bodyLarge)
if (cal.group) {
Spacer(Modifier.size(6.dp))
Icon(Icons.Filled.People, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
val by = cal.sharedBy
if (!cal.owned && by != null) {
Text(tr("accounts.shared_by", by), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = tr("accounts.action.share"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
if (cal.owned && !cal.group) {
DropdownMenuItem(text = { Text(tr("accounts.action.share")) }, onClick = { menu = false; onShare() })
}
if (cal.owned || cal.permission == "read_write") {
DropdownMenuItem(text = { Text(tr("accounts.action.import")) }, onClick = { menu = false; onImport() })
}
DropdownMenuItem(text = { Text(tr("accounts.action.export")) }, onClick = { menu = false; onExport() })
if (cal.owned) {
DropdownMenuItem(text = { Text(tr("common.delete")) }, onClick = { menu = false; onDelete() })
}
}
}
}
}
/** Manage who a local calendar is shared with (owner only). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -> Unit) {
LaunchedEffect(calendarId) { vm.loadSharing(calendarId) }
var permission by remember { mutableStateOf("read") }
var search by remember { mutableStateOf("") }
val sharedIds = vm.shares.map { it.userId }.toSet()
val candidates = vm.directory.filter {
it.id !in sharedIds && (search.isBlank() || it.displayName.contains(search, ignoreCase = true))
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()),
) {
Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(16.dp))
Text(tr("share.with"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (vm.shares.isEmpty()) {
Text(tr("share.none"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 6.dp))
} else {
vm.shares.forEach { s ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(s.displayName, modifier = Modifier.weight(1f))
Text(
if (s.permission == "read_write") tr("share.permission.read_write") else tr("share.permission.read"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = { vm.removeShare(calendarId, s.userId) }) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
}
}
}
}
Divider(Modifier.padding(vertical = 16.dp))
Text(tr("share.add"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = permission == "read", onClick = { permission = "read" }, label = { Text(tr("share.permission.read")) })
FilterChip(selected = permission == "read_write", onClick = { permission = "read_write" }, label = { Text(tr("share.permission.read_write")) })
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text(tr("share.search")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(4.dp))
candidates.forEach { u ->
Row(
Modifier.fillMaxWidth().clickable { vm.addShare(calendarId, u.id, permission) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(u.displayName, modifier = Modifier.weight(1f))
Icon(Icons.Filled.Add, contentDescription = tr("share.add"), tint = MaterialTheme.colorScheme.primary)
}
}
}
}
}
// ---- Add dialogs ----
@Composable

View File

@@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription
@@ -85,4 +87,69 @@ class AccountsViewModel @Inject constructor(
fun deleteGoogle(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteGoogleAccount(id) }
// ---- Calendar colour ----
fun setLocalColor(id: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.updateLocalCalendarColor(id, color) }
fun setICalColor(id: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.updateICalColor(id, color) }
fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) }
// ---- Sharing ----
var shares by mutableStateOf<List<CalendarShareEntry>>(emptyList())
private set
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
private set
fun loadSharing(calendarId: Int) {
viewModelScope.launch {
shares = runCatching { repository.getShares(calendarId) }.getOrDefault(emptyList())
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
}
}
fun addShare(calendarId: Int, userId: Int, permission: String) {
viewModelScope.launch {
runCatching { repository.addShare(calendarId, userId, permission) }
.onSuccess { loadSharing(calendarId) }
.onFailure { error = it.message }
}
}
fun removeShare(calendarId: Int, userId: Int) {
viewModelScope.launch {
runCatching { repository.removeShare(calendarId, userId) }
.onSuccess { loadSharing(calendarId) }
.onFailure { error = it.message }
}
}
// ---- Import / export ----
var infoMessage by mutableStateOf<String?>(null)
private set
fun clearInfo() { infoMessage = null }
fun clearError() { error = null }
fun importIcs(calendarId: Int, bytes: ByteArray, filename: String, result: (Int, Int) -> String, onChanged: () -> Unit) {
viewModelScope.launch {
runCatching { repository.importIcsFile(calendarId, bytes, filename) }
.onSuccess { (imported, skipped, _) -> infoMessage = result(imported, skipped); load(); onChanged() }
.onFailure { error = it.message }
}
}
fun exportIcs(calendarId: Int, onBytes: (ByteArray) -> Unit) {
viewModelScope.launch {
runCatching { repository.exportIcs(calendarId) }
.onSuccess { onBytes(it) }
.onFailure { error = it.message }
}
}
}

View File

@@ -188,6 +188,7 @@ fun CalendarScreen(
if (ev != null) {
EventDetailScreen(
event = ev,
currentUserId = vm.currentUserId,
onClose = { detailEvent = null },
onEdit = {
detailEvent = null
@@ -210,8 +211,8 @@ fun CalendarScreen(
request = req,
writableCalendars = state.writableCalendars,
onDismiss = { editor = null },
onSave = { cal, title, start, end, allDay, location, desc, color ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error ->
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error ->
if (error == null) editor = null
}
},

View File

@@ -48,6 +48,9 @@ class CalendarViewModel @Inject constructor(
private val zone: ZoneId = ZoneId.systemDefault()
/** Current user id (for creator/owner comparisons in the UI). */
val currentUserId: Int get() = repository.currentUserId
/** Serializes network loads so overlapping fetches don't thrash the UI. */
private val loadMutex = Mutex()
@@ -298,20 +301,21 @@ class CalendarViewModel @Inject constructor(
location: String,
description: String,
color: String?,
isPrivate: Boolean,
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)
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate)
"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)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
}
} else {
createForSource(calendar, title, start, end, isAllDay, location, description, color)
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
}
}
result.onSuccess { afterMutation(); onResult(null) }
@@ -321,10 +325,10 @@ class CalendarViewModel @Inject constructor(
private suspend fun createForSource(
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
) {
when (calendar.source) {
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate)
"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)

View File

@@ -23,7 +23,9 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notes
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -60,6 +62,7 @@ import com.scarriffle.calendarr.util.colorFromHex
@Composable
fun EventDetailScreen(
event: CalEvent,
currentUserId: Int = 0,
onClose: () -> Unit,
onEdit: () -> Unit,
onCopy: () -> Unit,
@@ -109,6 +112,12 @@ fun EventDetailScreen(
if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes)
if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() })
event.creator?.let { c ->
if (c.id != currentUserId) {
DetailRow(Icons.Filled.Person, "${tr("event.created_by")}: ${c.displayName}")
}
}
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
Spacer(Modifier.height(28.dp))
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {

View File

@@ -69,7 +69,7 @@ fun EventEditorSheet(
request: EditorRequest,
writableCalendars: List<WritableCalendar>,
onDismiss: () -> Unit,
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?) -> Unit,
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit,
) {
val zone = ZoneId.systemDefault()
val context = LocalContext.current
@@ -85,6 +85,7 @@ fun EventEditorSheet(
var location by remember { mutableStateOf(template?.location ?: "") }
var description by remember { mutableStateOf(template?.notes ?: "") }
var color by remember { mutableStateOf(template?.color) }
var isPrivate by remember { mutableStateOf(template?.isPrivate ?: false) }
val initialStart = template?.startDate
val initialEnd = template?.endDate
@@ -230,6 +231,15 @@ fun EventEditorSheet(
}
Spacer(Modifier.size(12.dp))
// Private (local calendars only)
if (calendar?.source == "local") {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
}
Spacer(Modifier.size(12.dp))
}
// Color
Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -270,7 +280,7 @@ fun EventEditorSheet(
start = startDate.atTime(startTime).atZone(zone).toInstant()
end = endDate.atTime(endTime).atZone(zone).toInstant()
}
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color)
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local")
},
enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),

View File

@@ -21,12 +21,17 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@@ -86,6 +91,9 @@ fun SettingsScreen(
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
ProfileChapter(vm)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.calview"))
ChipRow(
options = CalViewType.entries.map { it.key to tr("view.${it.key}") },
@@ -175,6 +183,80 @@ private fun Section(title: String) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
}
/** Server-backed "Profil" chapter: display name, login name, email, privacy, shared calendar. */
@Composable
private fun ProfileChapter(vm: SettingsViewModel) {
val savedLabel = tr("settings.saved")
Section(tr("settings.nav.profile"))
OutlinedTextField(
value = vm.displayName,
onValueChange = vm::onDisplayNameChange,
label = { Text(tr("profile.display_name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(tr("profile.login_name"), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(vm.loginName, fontWeight = FontWeight.Medium)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = vm.email,
onValueChange = vm::onEmailChange,
label = { Text(tr("profile.email")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
vm.profileMessage?.let {
Spacer(Modifier.size(8.dp))
Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.privacy"))
ChipRow(
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
selected = vm.privateVisibility,
onSelect = vm::changePrivateVisibility,
)
Spacer(Modifier.size(6.dp))
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.group_visible"))
CalendarDropdown(vm)
Spacer(Modifier.size(6.dp))
Text(tr("settings.group_visible.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CalendarDropdown(vm: SettingsViewModel) {
var expanded by remember { mutableStateOf(false) }
val noneLabel = tr("group.visible.none")
val selectedLabel = vm.ownLocalCalendars.firstOrNull { it.id == vm.groupVisibleId }?.name ?: noneLabel
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(tr("settings.group_visible")) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(text = { Text(noneLabel) }, onClick = { vm.changeGroupVisible(0); expanded = false })
vm.ownLocalCalendars.forEach { cal ->
DropdownMenuItem(text = { Text(cal.name) }, onClick = { vm.changeGroupVisible(cal.id); expanded = false })
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {

View File

@@ -1,10 +1,14 @@
package com.scarriffle.calendarr.ui.settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.LocalCalendar
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -27,4 +31,65 @@ class SettingsViewModel @Inject constructor(
var cacheMonths: Int
get() = settingsStore.cacheMonths
set(value) { settingsStore.cacheMonths = value }
// ---- Profile chapter (server-backed) ----
var displayName by mutableStateOf("")
var loginName by mutableStateOf("")
var email by mutableStateOf("")
private set
var privateVisibility by mutableStateOf("busy")
private set
var groupVisibleId by mutableStateOf(0) // 0 = none
private set
var ownLocalCalendars by mutableStateOf<List<LocalCalendar>>(emptyList())
private set
var profileMessage by mutableStateOf<String?>(null)
private set
init { loadProfile() }
fun onDisplayNameChange(v: String) { displayName = v }
fun onEmailChange(v: String) { email = v }
private fun loadProfile() {
viewModelScope.launch {
runCatching { repository.getProfile() }.onSuccess { p ->
displayName = p.displayName ?: p.username
loginName = p.username
email = p.email ?: ""
}
runCatching { repository.getSettings() }.onSuccess { s ->
privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?: 0
}
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
ownLocalCalendars = cals.filter { it.owned && !it.group }
}
}
}
fun saveProfile(savedLabel: String) {
viewModelScope.launch {
runCatching {
repository.updateProfile(
displayName = displayName.trim().ifEmpty { null },
username = null,
email = email.trim(),
)
}
.onSuccess { profileMessage = savedLabel }
.onFailure { profileMessage = it.message }
}
}
fun changePrivateVisibility(value: String) {
privateVisibility = value
viewModelScope.launch { runCatching { repository.updatePrivateVisibility(value) } }
}
fun changeGroupVisible(id: Int) {
groupVisibleId = id
viewModelScope.launch { runCatching { repository.updateGroupVisibleCalendar(id.takeIf { it != 0 }) } }
}
}