feat: per-setting sync table with dynamic 8-colour theme

Redesign the settings screen into uniform rows (sync icon | name | value):
dropdowns replace the chip pickers, every colour gets a reset to a canonical
default, a per-row sync icon toggles that setting's cross-device sync, and a
global switch flips all. Enabling a flag adopts this device's current value.

- AppSettings/SettingsStore: add text/bg/line colours, cache_months,
  month_view_paged, and the account-wide sync_flags map (+ DEFAULT_SYNC,
  effective-merge on pull)
- CalendarRepository.updateSettings: partial PUT of only the synced values plus
  the flag map (unsynced columns stay untouched server-side)
- Theme: fully dynamic — primary/accent tint, background/text/line drive
  background/onBackground/outline (matching web); today/divider/label unchanged
- add hide-profile (directory_hidden) to the profile block (parity with web/iOS)
- language + text/line contrast stay device-local

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-07-14 22:09:46 +02:00
parent fdad9c9ba4
commit fd804000c5
9 changed files with 460 additions and 188 deletions

View File

@@ -118,27 +118,31 @@ class CalendarRepository @Inject constructor(
suspend fun getSettings(): AppSettings = guarded { api.getSettings() } suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
suspend fun updateSettings(s: AppSettings) = guarded { /** Push only the values whose sync flag is on (partial update; the server
api.updateSettings( * leaves unsynced columns untouched), plus the account-wide flag map. */
jsonBody( suspend fun updateSettings(s: AppSettings, flags: Map<String, Boolean>) = guarded {
"default_view" to s.defaultView, val body = mutableMapOf<String, Any?>()
"week_start_day" to s.weekStartDay, fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value }
"primary_color" to s.primaryColor, addIf("default_view", s.defaultView)
"accent_color" to s.accentColor, addIf("week_start_day", s.weekStartDay)
"today_color" to s.todayColor, addIf("dim_past_events", s.dimPastEvents)
"dim_past_events" to s.dimPastEvents, addIf("hour_height", s.hourHeight)
"text_contrast" to s.textContrast, addIf("default_event_duration_minutes", s.defaultEventDurationMinutes)
"line_contrast" to s.lineContrast, // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
"hour_height" to s.hourHeight, addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL)
"language" to s.language, addIf("primary_color", s.primaryColor)
"month_divider_color" to s.monthDividerColor, addIf("accent_color", s.accentColor)
"month_label_color" to s.monthLabelColor, addIf("today_color", s.todayColor)
"private_event_visibility" to s.privateEventVisibility, addIf("text_color", s.textColor)
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. addIf("bg_color", s.backgroundColor)
"default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL), addIf("line_color", s.lineColor)
"default_event_duration_minutes" to s.defaultEventDurationMinutes, addIf("month_divider_color", s.monthDividerColor)
) addIf("month_label_color", s.monthLabelColor)
).ensureSuccess() 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() } suspend fun getProfile(): UserProfile = guarded { api.getProfile() }
@@ -499,8 +503,13 @@ class CalendarRepository @Inject constructor(
// ---- Profile & targeted settings ---- // ---- Profile & targeted settings ----
suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded { 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)) val resp = api.updateProfile(jsonBody(
"display_name" to displayName,
"username" to username,
"email" to email,
"directory_hidden" to directoryHidden,
))
resp.ensureSuccess() resp.ensureSuccess()
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull() runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
} }

View File

@@ -34,8 +34,13 @@ class SettingsStore @Inject constructor(
language = prefs.getString(K_LANGUAGE, null) ?: "de", language = prefs.getString(K_LANGUAGE, null) ?: "de",
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, 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 }, defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60), 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) { fun saveSettings(s: AppSettings) {
@@ -52,11 +57,97 @@ class SettingsStore @Inject constructor(
.putString(K_LANGUAGE, s.language) .putString(K_LANGUAGE, s.language)
.putString(K_DIVIDER, s.monthDividerColor) .putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor) .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_REMINDER, s.defaultReminderMinutes ?: -1)
.putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes) .putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes)
.putInt(K_CACHE_MONTHS, s.cacheMonths)
.putBoolean(K_MONTH_PAGED, s.monthViewPaged)
.apply() .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<String> = 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<String, Boolean> = syncableKeys.associateWith { key ->
key != "cache_months" && key != "month_view_paged"
}
fun loadSyncFlags(): Map<String, Boolean> {
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<String, Boolean>) {
prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply()
}
fun storeServerFlags(server: Map<String, Boolean>?) {
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). */ /** Device-local cache range in months around today (default 3). */
var cacheMonths: Int var cacheMonths: Int
get() = prefs.getInt(K_CACHE_MONTHS, 3) get() = prefs.getInt(K_CACHE_MONTHS, 3)
@@ -102,6 +193,10 @@ class SettingsStore @Inject constructor(
const val K_LANGUAGE = "language" const val K_LANGUAGE = "language"
const val K_DIVIDER = "month_divider_color" const val K_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_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_REMINDER = "default_reminder_minutes"
const val K_DEFAULT_DURATION = "default_event_duration_minutes" const val K_DEFAULT_DURATION = "default_event_duration_minutes"
const val K_CACHE_MONTHS = "cache_months" const val K_CACHE_MONTHS = "cache_months"

View File

@@ -98,6 +98,7 @@ data class UserProfile(
@Json(name = "is_admin") val isAdmin: Boolean = false, @Json(name = "is_admin") val isAdmin: Boolean = false,
@Json(name = "has_avatar") val hasAvatar: Boolean = false, @Json(name = "has_avatar") val hasAvatar: Boolean = false,
@Json(name = "totp_enabled") val totpEnabled: 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). */ /** A calendar the user can create events in (resolved from all writable sources). */

View File

@@ -22,6 +22,11 @@ data class AppSettings(
@Json(name = "language") val language: String = "de", @Json(name = "language") val language: String = "de",
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
@Json(name = "month_label_color") val monthLabelColor: 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'. // How this user's private events appear to other group members: 'hidden' | 'busy'.
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy", @Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, @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, @Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null,
// Duration (minutes) applied to a newly created event's end time. // Duration (minutes) applied to a newly created event's end time.
@Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60, @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<String, Boolean>? = null,
) { ) {
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
} }

View File

@@ -55,6 +55,20 @@ object L10n {
"settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe", "settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe",
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe", "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.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.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel", "settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum", "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.colors" to "Colors", "settings.color.primary" to "Primary color",
"settings.color.accent" to "Accent color", "settings.color.today" to "Today 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.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.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium", "settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum", "settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",

View File

@@ -62,12 +62,12 @@ class MainViewModel @Inject constructor(
_route.value = computeRoute() _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() { fun refreshSettings() {
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.getSettings() }.onSuccess { s -> runCatching { repository.getSettings() }.onSuccess { s ->
settingsStore.saveSettings(s) _settings.value = settingsStore.applyServerPull(s)
_settings.value = s
} }
} }
} }

View File

@@ -3,31 +3,29 @@ package com.scarriffle.calendarr.ui.settings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll 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.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
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
@@ -36,6 +34,7 @@ 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
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.colorFromHex
import com.scarriffle.calendarr.util.toHex import com.scarriffle.calendarr.util.toHex
private val PALETTE = listOf( // Canonical default colours (single source for the reset buttons).
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", 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) @OptIn(ExperimentalMaterial3Api::class)
@@ -70,8 +72,6 @@ fun SettingsScreen(
) { ) {
val initialSettings = LocalAppSettings.current val initialSettings = LocalAppSettings.current
var settings by remember { mutableStateOf(initialSettings) } var settings by remember { mutableStateOf(initialSettings) }
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
var monthPaged by remember { mutableStateOf(vm.monthViewPaged) }
fun update(newSettings: AppSettings) { fun update(newSettings: AppSettings) {
settings = newSettings settings = newSettings
@@ -79,6 +79,10 @@ fun SettingsScreen(
vm.apply(newSettings, onSettingsSynced) 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) { Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -92,117 +96,236 @@ fun SettingsScreen(
) { padding -> ) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { 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) 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)) Divider(Modifier.padding(vertical = 16.dp))
// ---- Termine (synced) ----
Section(tr("settings.calview")) Section(tr("settings.calview"))
ChipRow( SyncDropdownRow(
options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, tr("settings.default_duration"),
selected = settings.defaultView, 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"),
onSelect = { update(settings.copy(defaultView = it)) }, settings.defaultEventDurationMinutes.toString(),
{ update(settings.copy(defaultEventDurationMinutes = it.toInt())) },
synced("default_event_duration_minutes"), { toggle("default_event_duration_minutes") },
) )
Spacer(Modifier.size(16.dp)) SyncDropdownRow(
tr("settings.defaultreminder"),
Section(tr("settings.firstweekday")) reminderOptions(),
ChipRow( (settings.defaultReminderMinutes ?: -1).toString(),
options = listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")), { update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) },
selected = settings.weekStartDay, synced("default_reminder_minutes"), { toggle("default_reminder_minutes") },
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)) },
) )
Divider(Modifier.padding(vertical = 16.dp)) 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")) Section(tr("settings.colors"))
ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) }
ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) }
ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) }
ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) } SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) }
ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = 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)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.hourheight")) // ---- Cache (synced) ----
ChipRow( SyncDropdownRow(
options = listOf( tr("settings.cache.range"),
"28" to tr("settings.hourheight.compact"), 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")),
"44" to tr("settings.hourheight.normal"), settings.cacheMonths.toString(),
"60" to tr("settings.hourheight.comfort"), { update(settings.copy(cacheMonths = it.toInt())) },
"80" to tr("settings.hourheight.large"), synced("cache_months"), { toggle("cache_months") },
),
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())) },
) )
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.cache.title")) // ---- Gerät (device-local: language + contrast) ----
ChipRow( Section(tr("settings.device"))
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")), DeviceDropdownRow(
selected = cacheMonths.toString(), tr("settings.language"),
onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() }, 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)) Spacer(Modifier.size(40.dp))
} }
} }
} }
} }
@Composable
private fun reminderOptions(): List<Pair<String, String>> = 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 @Composable
private fun Section(title: String) { 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. */ /** 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<Pair<String, String>>, 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<Pair<String, String>>,
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<Pair<String, String>>,
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 @Composable
private fun ProfileChapter(vm: SettingsViewModel) { private fun ProfileChapter(vm: SettingsViewModel) {
val savedLabel = tr("settings.saved") val savedLabel = tr("settings.saved")
@@ -229,6 +352,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.size(8.dp)) 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")) } Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
vm.profileMessage?.let { vm.profileMessage?.let {
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
@@ -237,11 +368,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.privacy")) Section(tr("settings.privacy"))
ChipRow( Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")), Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
selected = vm.privateVisibility, Dropdown(
onSelect = vm::changePrivateVisibility, listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
) vm.privateVisibility,
vm::changePrivateVisibility,
)
}
Spacer(Modifier.size(6.dp)) Spacer(Modifier.size(6.dp))
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
@@ -275,35 +409,3 @@ private fun CalendarDropdown(vm: SettingsViewModel) {
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChipRow(options: List<Pair<String, String>>, 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) },
)
}
}

View File

@@ -19,23 +19,35 @@ class SettingsViewModel @Inject constructor(
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
) : ViewModel() { ) : 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) { fun apply(settings: AppSettings, onSynced: () -> Unit) {
settingsStore.saveSettings(settings) settingsStore.saveSettings(settings)
viewModelScope.launch { viewModelScope.launch {
runCatching { repository.updateSettings(settings) } runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) }
onSynced() onSynced()
} }
} }
var cacheMonths: Int // ---- Per-setting sync flags ----
get() = settingsStore.cacheMonths
set(value) { settingsStore.cacheMonths = value }
/** Device-local: month view as horizontal paged (swipe) vs. scroll feed. */ val syncableKeys: List<String> get() = settingsStore.syncableKeys
var monthViewPaged: Boolean var syncFlags by mutableStateOf(settingsStore.loadSyncFlags())
get() = settingsStore.monthViewPaged private set
set(value) { settingsStore.monthViewPaged = value }
/** 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) ---- // ---- Profile chapter (server-backed) ----
@@ -43,6 +55,8 @@ class SettingsViewModel @Inject constructor(
var loginName by mutableStateOf("") var loginName by mutableStateOf("")
var email by mutableStateOf("") var email by mutableStateOf("")
private set private set
var directoryHidden by mutableStateOf(false)
private set
var privateVisibility by mutableStateOf("busy") var privateVisibility by mutableStateOf("busy")
private set private set
var groupVisibleId by mutableStateOf(0) // 0 = none var groupVisibleId by mutableStateOf(0) // 0 = none
@@ -56,6 +70,7 @@ class SettingsViewModel @Inject constructor(
fun onDisplayNameChange(v: String) { displayName = v } fun onDisplayNameChange(v: String) { displayName = v }
fun onEmailChange(v: String) { email = v } fun onEmailChange(v: String) { email = v }
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
private fun loadProfile() { private fun loadProfile() {
viewModelScope.launch { viewModelScope.launch {
@@ -63,6 +78,7 @@ class SettingsViewModel @Inject constructor(
displayName = p.displayName ?: p.username displayName = p.displayName ?: p.username
loginName = p.username loginName = p.username
email = p.email ?: "" email = p.email ?: ""
directoryHidden = p.directoryHidden
} }
runCatching { repository.getSettings() }.onSuccess { s -> runCatching { repository.getSettings() }.onSuccess { s ->
privateVisibility = s.privateEventVisibility privateVisibility = s.privateEventVisibility
@@ -83,6 +99,7 @@ class SettingsViewModel @Inject constructor(
displayName = displayName.trim().ifEmpty { null }, displayName = displayName.trim().ifEmpty { null },
username = null, username = null,
email = email.trim(), email = email.trim(),
directoryHidden = directoryHidden,
) )
} }
.onSuccess { profileMessage = savedLabel } .onSuccess { profileMessage = savedLabel }

View File

@@ -7,50 +7,60 @@ import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor import com.scarriffle.calendarr.util.contrastingTextColor
/** /** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's
* The Calendarr brand accent — the green from the iOS `AccentColor` asset * primary colour is unset. */
* (#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]).
*/
val BrandGreen = Color(0xFF20A050) 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 @Composable
fun CalendarrTheme( fun CalendarrTheme(
settings: AppSettings = AppSettings(), settings: AppSettings = AppSettings(),
content: @Composable () -> Unit, 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( val colors = darkColorScheme(
primary = primary, primary = primary,
onPrimary = primary.contrastingTextColor(), onPrimary = primary.contrastingTextColor(),
primaryContainer = container, primaryContainer = container,
onPrimaryContainer = onContainer, onPrimaryContainer = onContainer,
secondary = primary, secondary = accent,
onSecondary = primary.contrastingTextColor(), onSecondary = accent.contrastingTextColor(),
secondaryContainer = container, secondaryContainer = container,
onSecondaryContainer = onContainer, onSecondaryContainer = onContainer,
tertiary = primary, tertiary = accent,
onTertiary = primary.contrastingTextColor(), onTertiary = accent.contrastingTextColor(),
tertiaryContainer = container, tertiaryContainer = container,
onTertiaryContainer = onContainer, onTertiaryContainer = onContainer,
surfaceTint = primary, surfaceTint = primary,
background = Color(0xFF000000), background = bg,
onBackground = Color(0xFFF2F2F7), onBackground = onBg,
surface = Color(0xFF1C1C1E), surface = surface,
onSurface = Color(0xFFF2F2F7), onSurface = onBg,
surfaceVariant = Color(0xFF2C2C2E), surfaceVariant = surfaceVariant,
onSurfaceVariant = Color(0xFFBEBEC4), onSurfaceVariant = onBg.copy(alpha = 0.7f),
outline = Color(0xFF3A3A3C), outline = line,
) )
MaterialTheme( MaterialTheme(