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.
|
||||
- `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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(" · ")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue