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 9ef7771..5583eed 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -118,27 +118,31 @@ class CalendarRepository @Inject constructor( suspend fun getSettings(): AppSettings = guarded { api.getSettings() } - suspend fun updateSettings(s: AppSettings) = guarded { - api.updateSettings( - jsonBody( - "default_view" to s.defaultView, - "week_start_day" to s.weekStartDay, - "primary_color" to s.primaryColor, - "accent_color" to s.accentColor, - "today_color" to s.todayColor, - "dim_past_events" to s.dimPastEvents, - "text_contrast" to s.textContrast, - "line_contrast" to s.lineContrast, - "hour_height" to s.hourHeight, - "language" to s.language, - "month_divider_color" to s.monthDividerColor, - "month_label_color" to s.monthLabelColor, - "private_event_visibility" to s.privateEventVisibility, - // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. - "default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL), - "default_event_duration_minutes" to s.defaultEventDurationMinutes, - ) - ).ensureSuccess() + /** Push only the values whose sync flag is on (partial update; the server + * leaves unsynced columns untouched), plus the account-wide flag map. */ + suspend fun updateSettings(s: AppSettings, flags: Map) = guarded { + val body = mutableMapOf() + fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value } + addIf("default_view", s.defaultView) + addIf("week_start_day", s.weekStartDay) + addIf("dim_past_events", s.dimPastEvents) + addIf("hour_height", s.hourHeight) + addIf("default_event_duration_minutes", s.defaultEventDurationMinutes) + // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. + addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL) + addIf("primary_color", s.primaryColor) + addIf("accent_color", s.accentColor) + addIf("today_color", s.todayColor) + addIf("text_color", s.textColor) + addIf("bg_color", s.backgroundColor) + addIf("line_color", s.lineColor) + addIf("month_divider_color", s.monthDividerColor) + addIf("month_label_color", s.monthLabelColor) + addIf("cache_months", s.cacheMonths) + addIf("month_view_paged", s.monthViewPaged) + // The flag map is always account-wide; send it every push. + body["sync_flags"] = org.json.JSONObject(flags as Map<*, *>) + api.updateSettings(jsonBody(body)).ensureSuccess() } suspend fun getProfile(): UserProfile = guarded { api.getProfile() } @@ -499,8 +503,13 @@ class CalendarRepository @Inject constructor( // ---- 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)) + suspend fun updateProfile(displayName: String?, username: String?, email: String?, directoryHidden: Boolean? = null): String? = guarded { + val resp = api.updateProfile(jsonBody( + "display_name" to displayName, + "username" to username, + "email" to email, + "directory_hidden" to directoryHidden, + )) resp.ensureSuccess() runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull() } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt index b74bff7..ae4d177 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt @@ -34,8 +34,13 @@ class SettingsStore @Inject constructor( language = prefs.getString(K_LANGUAGE, null) ?: "de", monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", + textColor = prefs.getString(K_TEXT_COLOR, null) ?: "#FFFFFF", + backgroundColor = prefs.getString(K_BG_COLOR, null) ?: "#000000", + lineColor = prefs.getString(K_LINE_COLOR, null) ?: "#3A3A52", defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 }, defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60), + cacheMonths = prefs.getInt(K_CACHE_MONTHS, 3), + monthViewPaged = prefs.getBoolean(K_MONTH_PAGED, false), ) fun saveSettings(s: AppSettings) { @@ -52,11 +57,97 @@ class SettingsStore @Inject constructor( .putString(K_LANGUAGE, s.language) .putString(K_DIVIDER, s.monthDividerColor) .putString(K_LABEL, s.monthLabelColor) + .putString(K_TEXT_COLOR, s.textColor) + .putString(K_BG_COLOR, s.backgroundColor) + .putString(K_LINE_COLOR, s.lineColor) .putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1) .putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes) + .putInt(K_CACHE_MONTHS, s.cacheMonths) + .putBoolean(K_MONTH_PAGED, s.monthViewPaged) .apply() } + // --- Per-setting cross-device sync flags (account-wide; server is authority) --- + + /** Keys this Android client can sync. Excludes language (device "system" has + * no server value) and text/line contrast (iOS-style opacity, device-local). */ + val syncableKeys: List = listOf( + "default_view", "week_start_day", "dim_past_events", "hour_height", + "default_event_duration_minutes", "default_reminder_minutes", + "primary_color", "accent_color", "today_color", + "text_color", "bg_color", "line_color", + "month_divider_color", "month_label_color", + "cache_months", "month_view_paged", + ) + + private val defaultSync: Map = syncableKeys.associateWith { key -> + key != "cache_months" && key != "month_view_paged" + } + + fun loadSyncFlags(): Map { + val result = defaultSync.toMutableMap() + val raw = prefs.getString(K_SYNC_FLAGS, null) + if (!raw.isNullOrBlank()) { + runCatching { org.json.JSONObject(raw) }.getOrNull()?.let { obj -> + for (key in syncableKeys) if (obj.has(key)) result[key] = obj.getBoolean(key) + } + } + return result + } + + private fun writeSyncFlags(f: Map) { + prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply() + } + + fun storeServerFlags(server: Map?) { + if (server == null) return + val f = loadSyncFlags().toMutableMap() + for (key in syncableKeys) server[key]?.let { f[key] = it } + writeSyncFlags(f) + } + + fun setSyncFlag(key: String, on: Boolean) { + val f = loadSyncFlags().toMutableMap(); f[key] = on; writeSyncFlags(f) + } + + fun setAllSyncFlags(on: Boolean) { + val f = loadSyncFlags().toMutableMap(); for (key in syncableKeys) f[key] = on; writeSyncFlags(f) + } + + /** Merge a server snapshot with local values honouring the (refreshed) flags: + * synced keys take the server value, others keep the local value. Persists + * the result and returns the effective settings. */ + fun applyServerPull(server: AppSettings): AppSettings { + storeServerFlags(server.syncFlags) + val flags = loadSyncFlags() + val local = loadSettings() + fun on(key: String) = flags[key] == true + val eff = local.copy( + defaultView = if (on("default_view")) server.defaultView else local.defaultView, + weekStartDay = if (on("week_start_day")) server.weekStartDay else local.weekStartDay, + dimPastEvents = if (on("dim_past_events")) server.dimPastEvents else local.dimPastEvents, + hourHeight = if (on("hour_height")) server.hourHeight else local.hourHeight, + defaultEventDurationMinutes = if (on("default_event_duration_minutes")) server.defaultEventDurationMinutes else local.defaultEventDurationMinutes, + defaultReminderMinutes = if (on("default_reminder_minutes")) server.defaultReminderMinutes else local.defaultReminderMinutes, + primaryColor = if (on("primary_color")) server.primaryColor else local.primaryColor, + accentColor = if (on("accent_color")) server.accentColor else local.accentColor, + todayColor = if (on("today_color")) server.todayColor else local.todayColor, + textColor = if (on("text_color")) server.textColor else local.textColor, + backgroundColor = if (on("bg_color")) server.backgroundColor else local.backgroundColor, + lineColor = if (on("line_color")) server.lineColor else local.lineColor, + monthDividerColor = if (on("month_divider_color")) server.monthDividerColor else local.monthDividerColor, + monthLabelColor = if (on("month_label_color")) server.monthLabelColor else local.monthLabelColor, + cacheMonths = if (on("cache_months")) server.cacheMonths else local.cacheMonths, + monthViewPaged = if (on("month_view_paged")) server.monthViewPaged else local.monthViewPaged, + // Device-local (never synced): language + contrast levels. + language = local.language, + textContrast = local.textContrast, + lineContrast = local.lineContrast, + ) + saveSettings(eff) + return eff + } + /** Device-local cache range in months around today (default 3). */ var cacheMonths: Int get() = prefs.getInt(K_CACHE_MONTHS, 3) @@ -102,6 +193,10 @@ class SettingsStore @Inject constructor( const val K_LANGUAGE = "language" const val K_DIVIDER = "month_divider_color" const val K_LABEL = "month_label_color" + const val K_TEXT_COLOR = "text_color" + const val K_BG_COLOR = "bg_color" + const val K_LINE_COLOR = "line_color" + const val K_SYNC_FLAGS = "sync_flags" const val K_DEFAULT_REMINDER = "default_reminder_minutes" const val K_DEFAULT_DURATION = "default_event_duration_minutes" const val K_CACHE_MONTHS = "cache_months" diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt index 602572b..68fd569 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -98,6 +98,7 @@ data class UserProfile( @Json(name = "is_admin") val isAdmin: Boolean = false, @Json(name = "has_avatar") val hasAvatar: Boolean = false, @Json(name = "totp_enabled") val totpEnabled: Boolean = false, + @Json(name = "directory_hidden") val directoryHidden: Boolean = false, ) /** A calendar the user can create events in (resolved from all writable sources). */ diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt index d96a998..b286fbc 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -22,6 +22,11 @@ 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", + // Text / background / line colours drive the Material theme (onBackground, + // background, outline) so the whole app follows them, matching web/iOS. + @Json(name = "text_color") val textColor: String = "#FFFFFF", + @Json(name = "bg_color") val backgroundColor: String = "#000000", + @Json(name = "line_color") val lineColor: String = "#3A3A52", // 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, @@ -29,6 +34,11 @@ data class AppSettings( @Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null, // Duration (minutes) applied to a newly created event's end time. @Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60, + // Preload range in months (device-local by default) and month-view paging. + @Json(name = "cache_months") val cacheMonths: Int = 3, + @Json(name = "month_view_paged") val monthViewPaged: Boolean = false, + // Account-wide per-setting sync flags, fully resolved by the server. + @Json(name = "sync_flags") val syncFlags: Map? = null, ) { val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" } 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 1a84005..aa750af 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -55,6 +55,20 @@ object L10n { "settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe", "settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe", "settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel", + "settings.color.text" to "Schriftfarbe", "settings.color.background" to "Hintergrundfarbe", + "settings.color.line" to "Linienfarbe", + "settings.sync_all" to "Alle synchronisieren", + "settings.sync_all.desc" to "Diese Einstellungen zwischen deinen Geräten teilen", + "settings.sync_this" to "Zwischen Geräten synchronisieren", + "settings.reset" to "Zurücksetzen", + "settings.appearance" to "Ansicht", + "settings.device" to "Nur auf diesem Gerät", + "settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", + "settings.directory_hidden" to "Profil verbergen", + "settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.", + "settings.defaultreminder" to "Standard-Erinnerung", + "reminder.off" to "Aus", "reminder.at_start" to "Zur Startzeit", + "reminder.1d" to "1 Tag vorher", "reminder.1w" to "1 Woche vorher", "settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast", "settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel", "settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum", @@ -214,6 +228,20 @@ object L10n { "settings.colors" to "Colors", "settings.color.primary" to "Primary color", "settings.color.accent" to "Accent color", "settings.color.today" to "Today color", "settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation", + "settings.color.text" to "Text color", "settings.color.background" to "Background color", + "settings.color.line" to "Line color", + "settings.sync_all" to "Sync all", + "settings.sync_all.desc" to "Share these settings across your devices", + "settings.sync_this" to "Sync across devices", + "settings.reset" to "Reset", + "settings.appearance" to "View", + "settings.device" to "This device only", + "settings.device.footer" to "These settings apply to this device only and are not synced.", + "settings.directory_hidden" to "Hide profile", + "settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.", + "settings.defaultreminder" to "Default reminder", + "reminder.off" to "Off", "reminder.at_start" to "At start time", + "reminder.1d" to "1 day before", "reminder.1w" to "1 week before", "settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast", "settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium", "settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt index 1714702..5303003 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt @@ -62,12 +62,12 @@ class MainViewModel @Inject constructor( _route.value = computeRoute() } - /** Pull appearance settings from the server, caching them locally. */ + /** Pull settings from the server and merge them with local values honouring + * each setting's sync flag (synced keys take the server value). */ fun refreshSettings() { viewModelScope.launch { runCatching { repository.getSettings() }.onSuccess { s -> - settingsStore.saveSettings(s) - _settings.value = s + _settings.value = settingsStore.applyServerPull(s) } } } 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 0bb5d37..22c6dac 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 @@ -3,31 +3,29 @@ package com.scarriffle.calendarr.ui.settings import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width 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.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh 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 @@ -36,6 +34,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -56,8 +55,11 @@ import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.toHex -private val PALETTE = listOf( - "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", +// Canonical default colours (single source for the reset buttons). +private val DEFAULT_COLORS = mapOf( + "primary_color" to "#4285F4", "accent_color" to "#EA4335", "today_color" to "#4285F4", + "text_color" to "#FFFFFF", "bg_color" to "#000000", "line_color" to "#3A3A52", + "month_divider_color" to "#7090C0", "month_label_color" to "#7090C0", ) @OptIn(ExperimentalMaterial3Api::class) @@ -70,8 +72,6 @@ fun SettingsScreen( ) { val initialSettings = LocalAppSettings.current var settings by remember { mutableStateOf(initialSettings) } - var cacheMonths by remember { mutableStateOf(vm.cacheMonths) } - var monthPaged by remember { mutableStateOf(vm.monthViewPaged) } fun update(newSettings: AppSettings) { settings = newSettings @@ -79,6 +79,10 @@ fun SettingsScreen( vm.apply(newSettings, onSettingsSynced) } + // Per-row sync toggle: enabling adopts this device's current value. + fun toggle(key: String) { vm.toggleSync(key, settings, onSettingsSynced) } + fun synced(key: String) = vm.syncFlags[key] == true + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Scaffold( topBar = { @@ -92,117 +96,236 @@ fun SettingsScreen( ) { padding -> Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { + // Global "sync everything" master switch. + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(tr("settings.sync_all"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(tr("settings.sync_all.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = vm.syncableKeys.all { vm.syncFlags[it] == true }, + onCheckedChange = { on -> vm.setAllSync(on, settings, onSettingsSynced) }, + ) + } + Divider(Modifier.padding(vertical = 16.dp)) + ProfileChapter(vm) - Spacer(Modifier.size(16.dp)) - Section(tr("settings.default_duration")) - ChipRow( - options = listOf( - "15" to "15 min", "30" to "30 min", "45" to "45 min", - "60" to "1 h", "90" to "1.5 h", "120" to "2 h", - ), - selected = settings.defaultEventDurationMinutes.toString(), - onSelect = { update(settings.copy(defaultEventDurationMinutes = it.toInt())) }, - ) Divider(Modifier.padding(vertical = 16.dp)) + // ---- Termine (synced) ---- Section(tr("settings.calview")) - ChipRow( - options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, - selected = settings.defaultView, - onSelect = { update(settings.copy(defaultView = it)) }, + SyncDropdownRow( + tr("settings.default_duration"), + listOf("15" to "15 min", "30" to "30 min", "45" to "45 min", "60" to "1 h", "90" to "1.5 h", "120" to "2 h"), + settings.defaultEventDurationMinutes.toString(), + { update(settings.copy(defaultEventDurationMinutes = it.toInt())) }, + synced("default_event_duration_minutes"), { toggle("default_event_duration_minutes") }, ) - Spacer(Modifier.size(16.dp)) - - Section(tr("settings.firstweekday")) - ChipRow( - options = listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")), - selected = settings.weekStartDay, - onSelect = { update(settings.copy(weekStartDay = it)) }, - ) - Spacer(Modifier.size(16.dp)) - - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(tr("settings.dimpast"), style = MaterialTheme.typography.bodyLarge) - Switch(checked = settings.dimPastEvents, onCheckedChange = { update(settings.copy(dimPastEvents = it)) }) - } - Spacer(Modifier.size(16.dp)) - - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(tr("settings.month_paged"), style = MaterialTheme.typography.bodyLarge) - Switch(checked = monthPaged, onCheckedChange = { monthPaged = it; vm.monthViewPaged = it }) - } - Spacer(Modifier.size(16.dp)) - - Divider(Modifier.padding(vertical = 16.dp)) - - Section(tr("settings.language")) - ChipRow( - options = listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")), - selected = settings.language, - onSelect = { update(settings.copy(language = it)) }, + SyncDropdownRow( + tr("settings.defaultreminder"), + reminderOptions(), + (settings.defaultReminderMinutes ?: -1).toString(), + { update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) }, + synced("default_reminder_minutes"), { toggle("default_reminder_minutes") }, ) Divider(Modifier.padding(vertical = 16.dp)) + // ---- Ansicht (synced) ---- + Section(tr("settings.appearance")) + SyncDropdownRow( + tr("settings.defaultview"), + CalViewType.entries.map { it.key to tr("view.${it.key}") }, + settings.defaultView, + { update(settings.copy(defaultView = it)) }, + synced("default_view"), { toggle("default_view") }, + ) + SyncDropdownRow( + tr("settings.firstweekday"), + listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")), + settings.weekStartDay, + { update(settings.copy(weekStartDay = it)) }, + synced("week_start_day"), { toggle("week_start_day") }, + ) + SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") }) + SyncSwitchRow(tr("settings.month_paged"), settings.monthViewPaged, { update(settings.copy(monthViewPaged = it)) }, synced("month_view_paged"), { toggle("month_view_paged") }) + SyncDropdownRow( + tr("settings.hourheight"), + listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")), + settings.hourHeight.toString(), + { update(settings.copy(hourHeight = it.toInt())) }, + synced("hour_height"), { toggle("hour_height") }, + ) + Divider(Modifier.padding(vertical = 16.dp)) + + // ---- Farben (synced) ---- Section(tr("settings.colors")) - ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } - ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } - ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } - ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) } - ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = it)) } + SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) } + SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) } + SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) } + SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) } + SyncColorRow("bg_color", tr("settings.color.background"), settings.backgroundColor, synced("bg_color"), { toggle("bg_color") }) { update(settings.copy(backgroundColor = it)) } + SyncColorRow("line_color", tr("settings.color.line"), settings.lineColor, synced("line_color"), { toggle("line_color") }) { update(settings.copy(lineColor = it)) } + SyncColorRow("month_divider_color", tr("settings.color.divider"), settings.monthDividerColor, synced("month_divider_color"), { toggle("month_divider_color") }) { update(settings.copy(monthDividerColor = it)) } + SyncColorRow("month_label_color", tr("settings.color.label"), settings.monthLabelColor, synced("month_label_color"), { toggle("month_label_color") }) { update(settings.copy(monthLabelColor = it)) } Divider(Modifier.padding(vertical = 16.dp)) - Section(tr("settings.hourheight")) - ChipRow( - options = listOf( - "28" to tr("settings.hourheight.compact"), - "44" to tr("settings.hourheight.normal"), - "60" to tr("settings.hourheight.comfort"), - "80" to tr("settings.hourheight.large"), - ), - selected = settings.hourHeight.toString(), - onSelect = { update(settings.copy(hourHeight = it.toInt())) }, - ) - Spacer(Modifier.size(16.dp)) - - Section(tr("settings.textcontrast")) - ChipRow( - options = listOf( - "1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), - "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max"), - ), - selected = settings.textContrast.toString(), - onSelect = { update(settings.copy(textContrast = it.toInt())) }, - ) - Spacer(Modifier.size(16.dp)) - Section(tr("settings.linecontrast")) - ChipRow( - options = listOf( - "1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), - "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong"), - ), - selected = settings.lineContrast.toString(), - onSelect = { update(settings.copy(lineContrast = it.toInt())) }, + // ---- Cache (synced) ---- + SyncDropdownRow( + tr("settings.cache.range"), + listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")), + settings.cacheMonths.toString(), + { update(settings.copy(cacheMonths = it.toInt())) }, + synced("cache_months"), { toggle("cache_months") }, ) Divider(Modifier.padding(vertical = 16.dp)) - Section(tr("settings.cache.title")) - ChipRow( - options = listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")), - selected = cacheMonths.toString(), - onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() }, - ) + // ---- Gerät (device-local: language + contrast) ---- + Section(tr("settings.device")) + DeviceDropdownRow( + tr("settings.language"), + listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")), + settings.language, + ) { update(settings.copy(language = it)) } + DeviceDropdownRow( + tr("settings.textcontrast"), + listOf("1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max")), + settings.textContrast.toString(), + ) { update(settings.copy(textContrast = it.toInt())) } + DeviceDropdownRow( + tr("settings.linecontrast"), + listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")), + settings.lineContrast.toString(), + ) { update(settings.copy(lineContrast = it.toInt())) } + Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp)) Spacer(Modifier.size(40.dp)) } } } } +@Composable +private fun reminderOptions(): List> = listOf( + "-1" to tr("reminder.off"), "0" to tr("reminder.at_start"), "5" to "5 min", "15" to "15 min", + "30" to "30 min", "60" to "1 h", "1440" to tr("reminder.1d"), "10080" to tr("reminder.1w"), +) + @Composable 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. */ +/** Leading per-row sync toggle: highlighted = synced across devices. */ +@Composable +private fun SyncIcon(on: Boolean, onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Filled.Refresh, + contentDescription = tr("settings.sync_this"), + tint = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Dropdown(options: List>, selected: String, onSelect: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: selected + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.width(170.dp)) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (key, label) -> + DropdownMenuItem(text = { Text(label) }, onClick = { onSelect(key); expanded = false }) + } + } + } +} + +@Composable +private fun SyncDropdownRow( + label: String, + options: List>, + selected: String, + onSelect: (String) -> Unit, + synced: Boolean, + onToggleSync: () -> Unit, +) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + SyncIcon(synced, onToggleSync) + Spacer(Modifier.size(8.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Dropdown(options, selected, onSelect) + } +} + +@Composable +private fun DeviceDropdownRow( + label: String, + options: List>, + selected: String, + onSelect: (String) -> Unit, +) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.size(44.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Dropdown(options, selected, onSelect) + } +} + +@Composable +private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, synced: Boolean, onToggleSync: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + SyncIcon(synced, onToggleSync) + Spacer(Modifier.size(8.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} + +@Composable +private fun SyncColorRow( + syncKey: String, + label: String, + current: String, + synced: Boolean, + onToggleSync: () -> Unit, + onPick: (String) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + SyncIcon(synced, onToggleSync) + Spacer(Modifier.size(8.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.size(12.dp)) + Box( + Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current)) + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + .clickable { showPicker = true }, + ) + TextButton(onClick = { onPick(DEFAULT_COLORS[syncKey] ?: "#000000") }, contentPadding = PaddingValues(horizontal = 8.dp)) { + Text(tr("settings.reset"), style = MaterialTheme.typography.labelSmall) + } + } + if (showPicker) { + ColorPickerDialog( + initial = current, + title = label, + onDismiss = { showPicker = false }, + onConfirm = { showPicker = false; onPick(it) }, + ) + } +} + +/** Server-backed "Profil" chapter: name, login, email, hide-profile, privacy, shared calendar. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ProfileChapter(vm: SettingsViewModel) { val savedLabel = tr("settings.saved") @@ -229,6 +352,14 @@ private fun ProfileChapter(vm: SettingsViewModel) { modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.size(8.dp)) + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(tr("settings.directory_hidden"), style = MaterialTheme.typography.bodyLarge) + Text(tr("settings.directory_hidden.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch(checked = vm.directoryHidden, onCheckedChange = vm::onDirectoryHiddenChange) + } + Spacer(Modifier.size(8.dp)) Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) } vm.profileMessage?.let { Spacer(Modifier.size(8.dp)) @@ -237,11 +368,14 @@ private fun ProfileChapter(vm: SettingsViewModel) { 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, - ) + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Dropdown( + listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")), + vm.privateVisibility, + 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)) @@ -275,35 +409,3 @@ private fun CalendarDropdown(vm: SettingsViewModel) { } } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ChipRow(options: List>, selected: String, onSelect: (String) -> Unit) { - Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { (key, label) -> - FilterChip(selected = key == selected, onClick = { onSelect(key) }, label = { Text(label) }) - } - } -} - -@Composable -private fun ColorRow(label: String, current: String, onPick: (String) -> Unit) { - var showPicker by remember { mutableStateOf(false) } - Row( - Modifier.fillMaxWidth().clickable { showPicker = true }.padding(vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current)).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)) - Spacer(Modifier.size(12.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - if (showPicker) { - ColorPickerDialog( - initial = current, - title = label, - onDismiss = { showPicker = false }, - onConfirm = { showPicker = false; onPick(it) }, - ) - } -} 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 43e146f..6f01ef6 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 @@ -19,23 +19,35 @@ class SettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, ) : ViewModel() { - /** Persist locally immediately, then sync to the server in the background. */ + /** Persist locally immediately, then push the synced values to the server. */ fun apply(settings: AppSettings, onSynced: () -> Unit) { settingsStore.saveSettings(settings) viewModelScope.launch { - runCatching { repository.updateSettings(settings) } + runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) } onSynced() } } - var cacheMonths: Int - get() = settingsStore.cacheMonths - set(value) { settingsStore.cacheMonths = value } + // ---- Per-setting sync flags ---- - /** Device-local: month view as horizontal paged (swipe) vs. scroll feed. */ - var monthViewPaged: Boolean - get() = settingsStore.monthViewPaged - set(value) { settingsStore.monthViewPaged = value } + val syncableKeys: List get() = settingsStore.syncableKeys + var syncFlags by mutableStateOf(settingsStore.loadSyncFlags()) + private set + + /** Toggle one setting's flag. Enabling adopts this device's current value + * (pushes the synced values up); disabling keeps the value local. */ + fun toggleSync(key: String, current: AppSettings, onSynced: () -> Unit) { + settingsStore.setSyncFlag(key, !(syncFlags[key] ?: false)) + syncFlags = settingsStore.loadSyncFlags() + viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() } + } + + /** Global "sync everything" switch. */ + fun setAllSync(on: Boolean, current: AppSettings, onSynced: () -> Unit) { + settingsStore.setAllSyncFlags(on) + syncFlags = settingsStore.loadSyncFlags() + viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() } + } // ---- Profile chapter (server-backed) ---- @@ -43,6 +55,8 @@ class SettingsViewModel @Inject constructor( var loginName by mutableStateOf("") var email by mutableStateOf("") private set + var directoryHidden by mutableStateOf(false) + private set var privateVisibility by mutableStateOf("busy") private set var groupVisibleId by mutableStateOf(0) // 0 = none @@ -56,6 +70,7 @@ class SettingsViewModel @Inject constructor( fun onDisplayNameChange(v: String) { displayName = v } fun onEmailChange(v: String) { email = v } + fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v } private fun loadProfile() { viewModelScope.launch { @@ -63,6 +78,7 @@ class SettingsViewModel @Inject constructor( displayName = p.displayName ?: p.username loginName = p.username email = p.email ?: "" + directoryHidden = p.directoryHidden } runCatching { repository.getSettings() }.onSuccess { s -> privateVisibility = s.privateEventVisibility @@ -83,6 +99,7 @@ class SettingsViewModel @Inject constructor( displayName = displayName.trim().ifEmpty { null }, username = null, email = email.trim(), + directoryHidden = directoryHidden, ) } .onSuccess { profileMessage = savedLabel } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt index e94dd19..f6e41f0 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt @@ -7,50 +7,60 @@ import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.unit.dp import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.contrastingTextColor -/** - * The Calendarr brand accent — the green from the iOS `AccentColor` asset - * (#20A050). This drives the global control tint (buttons, FAB, switches, - * top bar) regardless of the server's per-calendar colours, matching iOS - * where the app tint is fixed and `primary_color` only styles calendar - * elements (e.g. the "today" highlight, read from [AppSettings]). - */ +/** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's + * primary colour is unset. */ val BrandGreen = Color(0xFF20A050) +/** + * Fully dynamic theme: every colour is derived from [AppSettings] so the whole + * app follows the user's palette (matching the web client). primary/accent tint + * the controls; background/text/line drive `background`/`onBackground`/`outline`, + * which the calendar views already read (grid, secondary text). "today", divider + * and label colours are read directly in the views. + */ @Composable fun CalendarrTheme( settings: AppSettings = AppSettings(), content: @Composable () -> Unit, ) { - val primary = BrandGreen + val primary = colorFromHex(settings.primaryColor, BrandGreen) + val accent = colorFromHex(settings.accentColor, primary) + val bg = colorFromHex(settings.backgroundColor, Color(0xFF000000)) + val onBg = colorFromHex(settings.textColor, Color(0xFFF2F2F7)) + val line = colorFromHex(settings.lineColor, Color(0xFF3A3A52)) + + val surface = lerp(bg, Color.White, 0.10f) + val surfaceVariant = lerp(bg, Color.White, 0.17f) + val container = lerp(primary, Color.Black, 0.55f) + val onContainer = lerp(primary, Color.White, 0.75f) - val container = Color(0xFF14532D) - val onContainer = Color(0xFFB7F0C6) val colors = darkColorScheme( primary = primary, onPrimary = primary.contrastingTextColor(), primaryContainer = container, onPrimaryContainer = onContainer, - secondary = primary, - onSecondary = primary.contrastingTextColor(), + secondary = accent, + onSecondary = accent.contrastingTextColor(), secondaryContainer = container, onSecondaryContainer = onContainer, - tertiary = primary, - onTertiary = primary.contrastingTextColor(), + tertiary = accent, + onTertiary = accent.contrastingTextColor(), tertiaryContainer = container, onTertiaryContainer = onContainer, surfaceTint = primary, - background = Color(0xFF000000), - onBackground = Color(0xFFF2F2F7), - surface = Color(0xFF1C1C1E), - onSurface = Color(0xFFF2F2F7), - surfaceVariant = Color(0xFF2C2C2E), - onSurfaceVariant = Color(0xFFBEBEC4), - outline = Color(0xFF3A3A3C), + background = bg, + onBackground = onBg, + surface = surface, + onSurface = onBg, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onBg.copy(alpha = 0.7f), + outline = line, ) MaterialTheme(