diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 0073599..c1a5e69 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -210,6 +210,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) diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index 0927da0..d246d47 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -152,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 + @DELETE("api/local/calendars/{id}") suspend fun deleteLocalCalendar(@Path("id") id: Int): Response @@ -163,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 + @DELETE("api/ical/subscriptions/{id}") suspend fun deleteICalSubscription(@Path("id") id: Int): Response diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 8efdb94..5ce56ce 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -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.", @@ -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.", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index e03e8cb..cae525f 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -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>, selected: String, onSelect: (String) -> Unit) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt index 18f507d..159cf40 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt @@ -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>(emptyList()) + private set + var profileMessage by mutableStateOf(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 }) } } + } }