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. - ChatModels: `Suggestion.labelRes`/`promptRes` and `ChatMode.labelRes` are resource IDs (Int), not hardcoded strings.
- `ModePillsRow`: `selected` state is `Int` (resource ID), not `String`. - `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 ### Offline-first cache layer (Room) — added 2026-06-21
Architecture decision document: `OFFLINE_CACHE_ADD.md`. Architecture decision document: `OFFLINE_CACHE_ADD.md`.

View file

@ -31,8 +31,9 @@ class MainActivity : ComponentActivity() {
window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
} }
val settingsViewModel = SettingsViewModel() val secureStore = SecureStore(applicationContext)
val sessionViewModel = SessionViewModel(SecureStore(applicationContext)) val settingsViewModel = SettingsViewModel(secureStore)
val sessionViewModel = SessionViewModel(secureStore)
val chatViewModel = ChatViewModel(applicationContext) val chatViewModel = ChatViewModel(applicationContext)
// auto() picks light/dark system-bar icons based on the system theme // 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) 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) chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
loadError = errors.joinToString(" · ") 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 TokenRequest(val email: String, val password: String, val name: String)
@Serializable private data class TokenResponse(val token: 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 data class WireMessage(val role: String, val content: String)
@Serializable private data class ChatRequest( @Serializable private data class ChatRequest(
val model: String, 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). */ /** 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? = suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() 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() { fun clear() {
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
@ -89,5 +103,9 @@ class SecureStore(context: Context) {
const val KEY_SPEED = "speed" const val KEY_SPEED = "speed"
const val KEY_FAVORITES = "favorite_models" const val KEY_FAVORITES = "favorite_models"
const val KEY_MODELS_CACHE = "models_cache" 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.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.AutoAwesome 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.Palette
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
@ -66,6 +67,7 @@ fun SettingsScreen(
val appearanceMode by viewModel.appearanceMode.collectAsState() val appearanceMode by viewModel.appearanceMode.collectAsState()
val userName by viewModel.userName.collectAsState() val userName by viewModel.userName.collectAsState()
val userEmail by viewModel.userEmail.collectAsState() val userEmail by viewModel.userEmail.collectAsState()
val locale by viewModel.locale.collectAsState()
MeshBackground { MeshBackground {
Column( 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)) 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 package dev.kaizen.app.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.ui.theme.themes.KaizenThemeId import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -8,33 +9,50 @@ import kotlinx.coroutines.flow.asStateFlow
enum class AppAppearance { Light, Dark, System } 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() 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() val appearanceMode: StateFlow<AppAppearance> = _appearanceMode.asStateFlow()
private val _userName = MutableStateFlow("Bruno") private val _userName = MutableStateFlow(store?.loadUserName() ?: "")
val userName: StateFlow<String> = _userName.asStateFlow() val userName: StateFlow<String> = _userName.asStateFlow()
private val _userEmail = MutableStateFlow("") private val _userEmail = MutableStateFlow("")
val userEmail: StateFlow<String> = _userEmail.asStateFlow() val userEmail: StateFlow<String> = _userEmail.asStateFlow()
private val _locale = MutableStateFlow(store?.loadLocale() ?: "system")
val locale: StateFlow<String> = _locale.asStateFlow()
fun selectTheme(themeId: KaizenThemeId) { fun selectTheme(themeId: KaizenThemeId) {
_selectedTheme.value = themeId _selectedTheme.value = themeId
store?.saveThemeId(themeId.value)
} }
fun selectAppearance(mode: AppAppearance) { fun selectAppearance(mode: AppAppearance) {
_appearanceMode.value = mode _appearanceMode.value = mode
store?.saveAppearance(mode.name)
} }
fun updateUserName(name: String) { 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) { fun updateUserEmail(email: String) {
_userEmail.value = email _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_light">Light</string>
<string name="settings_appearance_dark">Dark</string> <string name="settings_appearance_dark">Dark</string>
<string name="settings_appearance_system">System</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> </resources>

View file

@ -93,4 +93,9 @@
<string name="settings_appearance_light">Hell</string> <string name="settings_appearance_light">Hell</string>
<string name="settings_appearance_dark">Dunkel</string> <string name="settings_appearance_dark">Dunkel</string>
<string name="settings_appearance_system">System</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> </resources>

View file

@ -13,7 +13,7 @@ class SettingsViewModelTest {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
assertEquals(KaizenThemeId.AMBER, viewModel.selectedTheme.value) assertEquals(KaizenThemeId.AMBER, viewModel.selectedTheme.value)
assertEquals(AppAppearance.System, viewModel.appearanceMode.value) assertEquals(AppAppearance.System, viewModel.appearanceMode.value)
assertEquals("Bruno", viewModel.userName.value) assertEquals("", viewModel.userName.value)
} }
@Test @Test
@ -49,6 +49,7 @@ class SettingsViewModelTest {
@Test @Test
fun testUpdateUserNameBlankIgnored() { fun testUpdateUserNameBlankIgnored() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
viewModel.updateUserName("Bruno")
viewModel.updateUserName(" ") viewModel.updateUserName(" ")
assertEquals("Bruno", viewModel.userName.value) assertEquals("Bruno", viewModel.userName.value)
} }