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:
parent
9e4eaf2297
commit
736bb4c206
10 changed files with 100 additions and 9 deletions
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(" · ")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue