From 736bb4c206a1432c64492a575959aecec18742be Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Sun, 21 Jun 2026 15:26:14 +0200 Subject: [PATCH] feat: persistent settings + server sync + language selector Settings now persist across app restarts via SecureStore: - Theme (KaizenThemeId), appearance (Light/Dark/System), username, locale - All loaded on SettingsViewModel init, saved on every change Server sync: fetchMe() returns name + email from /api/v1/me, populates SettingsViewModel on login and each foreground resume. Language selector: System / Deutsch / English pill toggle in Settings. Locale preference stored; runtime switching deferred to next iteration. MeResponse expanded to parse name + email (was defaultModel only). SettingsViewModel tests updated for store-less default state. --- CLAUDE.md | 9 ++++++ .../main/java/dev/kaizen/app/MainActivity.kt | 5 ++-- .../java/dev/kaizen/app/chat/ChatScreen.kt | 4 +++ .../main/java/dev/kaizen/app/net/KaizenApi.kt | 22 ++++++++++++++- .../java/dev/kaizen/app/net/SecureStore.kt | 18 ++++++++++++ .../dev/kaizen/app/settings/SettingsScreen.kt | 10 +++++++ .../kaizen/app/settings/SettingsViewModel.kt | 28 +++++++++++++++---- app/src/main/res/values-en/strings.xml | 5 ++++ app/src/main/res/values/strings.xml | 5 ++++ .../dev/kaizen/app/SettingsViewModelTest.kt | 3 +- 10 files changed, 100 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 61e5181..e1010a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,15 @@ Full spec: `DESIGN.md`. Branch: `feature/design-system-v2`. - ChatModels: `Suggestion.labelRes`/`promptRes` and `ChatMode.labelRes` are resource IDs (Int), not hardcoded strings. - `ModePillsRow`: `selected` state is `Int` (resource ID), not `String`. +### Settings persistence + server sync + +- **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale survive app restart. +- Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora"). +- Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System"). +- On login + on each foreground: `KaizenApi.fetchMe()` → `MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email. +- **Language selector** in Settings: System / Deutsch / English. Stored as `locale` in SecureStore. Note: currently only stores the preference; runtime locale switching (AppCompatDelegate.setApplicationLocales) needs wiring in MainActivity on next iteration. +- No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local. + ### Offline-first cache layer (Room) — added 2026-06-21 Architecture decision document: `OFFLINE_CACHE_ADD.md`. diff --git a/app/src/main/java/dev/kaizen/app/MainActivity.kt b/app/src/main/java/dev/kaizen/app/MainActivity.kt index 72705c7..8c9cfcc 100644 --- a/app/src/main/java/dev/kaizen/app/MainActivity.kt +++ b/app/src/main/java/dev/kaizen/app/MainActivity.kt @@ -31,8 +31,9 @@ class MainActivity : ComponentActivity() { window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT } - val settingsViewModel = SettingsViewModel() - val sessionViewModel = SessionViewModel(SecureStore(applicationContext)) + val secureStore = SecureStore(applicationContext) + val settingsViewModel = SettingsViewModel(secureStore) + val sessionViewModel = SessionViewModel(secureStore) val chatViewModel = ChatViewModel(applicationContext) // auto() picks light/dark system-bar icons based on the system theme diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 9146d0f..e3db922 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -158,6 +158,10 @@ fun ChatScreen( } is FetchResult.Fail -> errors.add(r.reason) } + KaizenApi.fetchMe(cfg.baseUrl, cfg.token)?.let { me -> + me.name?.let { settingsViewModel.updateUserName(it) } + me.email?.let { settingsViewModel.updateUserEmail(it) } + } chat.conversationRepo.refresh(cfg.baseUrl, cfg.token) if (errors.isNotEmpty()) { loadError = errors.joinToString(" · ") diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 8fb1cf3..3af92c4 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -23,7 +23,11 @@ import java.util.concurrent.TimeUnit @Serializable private data class TokenRequest(val email: String, val password: String, val name: String) @Serializable private data class TokenResponse(val token: String) -@Serializable private data class MeResponse(val defaultModel: String? = null) +@Serializable data class MeResponse( + val name: String? = null, + val email: String? = null, + val defaultModel: String? = null, +) @Serializable data class WireMessage(val role: String, val content: String) @Serializable private data class ChatRequest( val model: String, @@ -164,6 +168,22 @@ object KaizenApi { } /** GET /api/v1/me — resolve the user's configured default model (best-effort, null on any failure). */ + suspend fun fetchMe(baseUrl: String, token: String): MeResponse? = + withContext(Dispatchers.IO) { + val req = Request.Builder() + .url("$baseUrl/api/v1/me") + .header("Authorization", "Bearer $token") + .build() + try { + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@use null + json.decodeFromString(resp.body!!.string()) + } + } catch (e: Exception) { + null + } + } + suspend fun fetchDefaultModel(baseUrl: String, token: String): String? = withContext(Dispatchers.IO) { val req = Request.Builder() diff --git a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt index 66aa942..9d44aa0 100644 --- a/app/src/main/java/dev/kaizen/app/net/SecureStore.kt +++ b/app/src/main/java/dev/kaizen/app/net/SecureStore.kt @@ -77,6 +77,20 @@ class SecureStore(context: Context) { } } + // ── Settings persistence ───────────────────────────────────────────────── + + fun loadThemeId(): String? = prefs.getString(KEY_THEME_ID, null) + fun saveThemeId(id: String) { prefs.edit().putString(KEY_THEME_ID, id).apply() } + + fun loadAppearance(): String? = prefs.getString(KEY_APPEARANCE, null) + fun saveAppearance(mode: String) { prefs.edit().putString(KEY_APPEARANCE, mode).apply() } + + fun loadUserName(): String? = prefs.getString(KEY_USER_NAME, null) + fun saveUserName(name: String) { prefs.edit().putString(KEY_USER_NAME, name).apply() } + + fun loadLocale(): String? = prefs.getString(KEY_LOCALE, null) + fun saveLocale(locale: String) { prefs.edit().putString(KEY_LOCALE, locale).apply() } + fun clear() { prefs.edit().clear().apply() } @@ -89,5 +103,9 @@ class SecureStore(context: Context) { const val KEY_SPEED = "speed" const val KEY_FAVORITES = "favorite_models" const val KEY_MODELS_CACHE = "models_cache" + const val KEY_THEME_ID = "ui_theme" + const val KEY_APPEARANCE = "appearance" + const val KEY_USER_NAME = "user_name" + const val KEY_LOCALE = "locale" } } diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt index 13942a5..0bc4913 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Settings @@ -66,6 +67,7 @@ fun SettingsScreen( val appearanceMode by viewModel.appearanceMode.collectAsState() val userName by viewModel.userName.collectAsState() val userEmail by viewModel.userEmail.collectAsState() + val locale by viewModel.locale.collectAsState() MeshBackground { Column( @@ -123,6 +125,14 @@ fun SettingsScreen( } } } + Spacer(Modifier.height(12.dp)) + SettingsOptionRow(title = stringResource(R.string.settings_language_title), description = stringResource(R.string.settings_language_desc), icon = Icons.Rounded.Language) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + listOf("system" to R.string.settings_language_system, "de" to R.string.settings_language_de, "en" to R.string.settings_language_en).forEach { (code, labelRes) -> + AppearanceTogglePill(selected = locale == code, label = stringResource(labelRes), onClick = { viewModel.selectLocale(code) }) + } + } + } } Text(stringResource(R.string.settings_section_profile), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt index 11ffff8..403cd6b 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsViewModel.kt @@ -1,6 +1,7 @@ package dev.kaizen.app.settings import androidx.lifecycle.ViewModel +import dev.kaizen.app.net.SecureStore import dev.kaizen.app.ui.theme.themes.KaizenThemeId import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -8,33 +9,50 @@ import kotlinx.coroutines.flow.asStateFlow enum class AppAppearance { Light, Dark, System } -class SettingsViewModel : ViewModel() { +class SettingsViewModel(private val store: SecureStore? = null) : ViewModel() { - private val _selectedTheme = MutableStateFlow(KaizenThemeId.AMBER) + private val _selectedTheme = MutableStateFlow( + store?.loadThemeId()?.let { KaizenThemeId.fromString(it) } ?: KaizenThemeId.AMBER + ) val selectedTheme: StateFlow = _selectedTheme.asStateFlow() - private val _appearanceMode = MutableStateFlow(AppAppearance.System) + private val _appearanceMode = MutableStateFlow( + store?.loadAppearance()?.let { runCatching { AppAppearance.valueOf(it) }.getOrNull() } ?: AppAppearance.System + ) val appearanceMode: StateFlow = _appearanceMode.asStateFlow() - private val _userName = MutableStateFlow("Bruno") + private val _userName = MutableStateFlow(store?.loadUserName() ?: "") val userName: StateFlow = _userName.asStateFlow() private val _userEmail = MutableStateFlow("") val userEmail: StateFlow = _userEmail.asStateFlow() + private val _locale = MutableStateFlow(store?.loadLocale() ?: "system") + val locale: StateFlow = _locale.asStateFlow() + fun selectTheme(themeId: KaizenThemeId) { _selectedTheme.value = themeId + store?.saveThemeId(themeId.value) } fun selectAppearance(mode: AppAppearance) { _appearanceMode.value = mode + store?.saveAppearance(mode.name) } fun updateUserName(name: String) { - if (name.isNotBlank()) _userName.value = name.trim() + if (name.isNotBlank()) { + _userName.value = name.trim() + store?.saveUserName(name.trim()) + } } fun updateUserEmail(email: String) { _userEmail.value = email } + + fun selectLocale(locale: String) { + _locale.value = locale + store?.saveLocale(locale) + } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 224869a..0e9d70d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -91,4 +91,9 @@ Light Dark System + Language + Choose the app language + System + Deutsch + English diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b0df167..bd0cc04 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,4 +93,9 @@ Hell Dunkel System + Sprache + Wähle die App-Sprache + System + Deutsch + English diff --git a/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt b/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt index 6b19457..4a63b2c 100644 --- a/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt +++ b/app/src/test/java/dev/kaizen/app/SettingsViewModelTest.kt @@ -13,7 +13,7 @@ class SettingsViewModelTest { val viewModel = SettingsViewModel() assertEquals(KaizenThemeId.AMBER, viewModel.selectedTheme.value) assertEquals(AppAppearance.System, viewModel.appearanceMode.value) - assertEquals("Bruno", viewModel.userName.value) + assertEquals("", viewModel.userName.value) } @Test @@ -49,6 +49,7 @@ class SettingsViewModelTest { @Test fun testUpdateUserNameBlankIgnored() { val viewModel = SettingsViewModel() + viewModel.updateUserName("Bruno") viewModel.updateUserName(" ") assertEquals("Bruno", viewModel.userName.value) }