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>
This commit is contained in:
Guido Schmit
2026-05-31 22:07:06 +02:00
parent 4a44c20b12
commit 716dd01abb
5 changed files with 226 additions and 0 deletions

View File

@@ -210,6 +210,27 @@ class CalendarRepository @Inject constructor(
suspend fun deleteHomeAssistantAccount(id: Int) = suspend fun deleteHomeAssistantAccount(id: Int) =
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() } 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). */ /** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */
suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded { suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded {
val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden) val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden)

View File

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

View File

@@ -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", "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.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", "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 screens
"auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL", "auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL",
"auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.", "auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.",
@@ -223,6 +249,32 @@ object L10n {
"ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily", "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.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", "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 screens
"auth.server_title" to "Connect server", "auth.server_url" to "Server URL", "auth.server_title" to "Connect server", "auth.server_url" to "Server URL",
"auth.server_hint" to "Enter the address of your Calendarr server.", "auth.server_hint" to "Enter the address of your Calendarr server.",

View File

@@ -21,12 +21,17 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -86,6 +91,9 @@ fun SettingsScreen(
) { padding -> ) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
ProfileChapter(vm)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.calview")) Section(tr("settings.calview"))
ChipRow( ChipRow(
options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, 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)) 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) { 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 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.LocalCalendar
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -27,4 +31,65 @@ class SettingsViewModel @Inject constructor(
var cacheMonths: Int var cacheMonths: Int
get() = settingsStore.cacheMonths get() = settingsStore.cacheMonths
set(value) { settingsStore.cacheMonths = value } 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 }) } }
}
} }