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:
@@ -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,
|
||||
/** 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<String, Boolean>) = guarded {
|
||||
val body = mutableMapOf<String, Any?>()
|
||||
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.
|
||||
"default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL),
|
||||
"default_event_duration_minutes" to s.defaultEventDurationMinutes,
|
||||
)
|
||||
).ensureSuccess()
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<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). */
|
||||
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"
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<String, Boolean>? = null,
|
||||
) {
|
||||
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
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<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
|
||||
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<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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user