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 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user