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.
This commit is contained in:
Bruno Deanoz 2026-06-21 15:26:14 +02:00
parent 9e4eaf2297
commit 736bb4c206
10 changed files with 100 additions and 9 deletions

View file

@ -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`.

View file

@ -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

View file

@ -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(" · ")

View file

@ -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<MeResponse>(resp.body!!.string())
}
} catch (e: Exception) {
null
}
}
suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) {
val req = Request.Builder()

View file

@ -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"
}
}

View file

@ -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))

View file

@ -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<KaizenThemeId> = _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<AppAppearance> = _appearanceMode.asStateFlow()
private val _userName = MutableStateFlow("Bruno")
private val _userName = MutableStateFlow(store?.loadUserName() ?: "")
val userName: StateFlow<String> = _userName.asStateFlow()
private val _userEmail = MutableStateFlow("")
val userEmail: StateFlow<String> = _userEmail.asStateFlow()
private val _locale = MutableStateFlow(store?.loadLocale() ?: "system")
val locale: StateFlow<String> = _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)
}
}

View file

@ -91,4 +91,9 @@
<string name="settings_appearance_light">Light</string>
<string name="settings_appearance_dark">Dark</string>
<string name="settings_appearance_system">System</string>
<string name="settings_language_title">Language</string>
<string name="settings_language_desc">Choose the app language</string>
<string name="settings_language_system">System</string>
<string name="settings_language_de">Deutsch</string>
<string name="settings_language_en">English</string>
</resources>

View file

@ -93,4 +93,9 @@
<string name="settings_appearance_light">Hell</string>
<string name="settings_appearance_dark">Dunkel</string>
<string name="settings_appearance_system">System</string>
<string name="settings_language_title">Sprache</string>
<string name="settings_language_desc">Wähle die App-Sprache</string>
<string name="settings_language_system">System</string>
<string name="settings_language_de">Deutsch</string>
<string name="settings_language_en">English</string>
</resources>

View file

@ -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)
}