feat: Inter Variable font, theme picker wired end-to-end, settings i18n

Inter Variable (v4.1, 859 KB): full type scale with 9 weight axes
(W100–W900), negative tracking at display sizes, positive at labels.

Theme picker: SettingsViewModel now uses KaizenThemeId from the design
system (AMBER/BLAU/MONO/AURORA). MainActivity wires selectedTheme +
appearanceMode to KaizenTheme composable — switching themes in Settings
instantly propagates to the entire app.

No server sync needed — web stores theme in localStorage, app stores
locally. Backend /api/v1/me has no uiTheme field in the DB.

Settings strings migrated to string resources (de + en).
SettingsViewModelTest updated for new enum values + System default.
This commit is contained in:
Bruno Deanoz 2026-06-21 15:17:17 +02:00
parent f83d4b4e17
commit 515a71efb5
8 changed files with 181 additions and 184 deletions

View file

@ -71,7 +71,7 @@ Full spec: `DESIGN.md`. Branch: `feature/design-system-v2`.
2. **Squircle shapes** — iOS-style superellipse with G2 continuity (`ui/shape/Squircle.kt`). Token system `KaizenShapes.{xs,sm,md,lg,xl,pill,circle}`. Replaces all `RoundedCornerShape`.
3. **GlassSurface** — frosted-glass composable (gradient tint + specular border + inner highlight + multi-layer shadow). Android has no backdrop-filter; glass effect works via semi-transparent tint over the blob layer. Opacity tiers per component in `GlassTiers`.
4. **Multi-layer shadows**`ShadowStack` with 24 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`.
5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. Server sync planned via `GET /api/v1/me``uiTheme`.
5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. Theme selection wired end-to-end: `SettingsViewModel.selectedTheme``MainActivity``KaizenTheme(themeId=...)`. No server sync (web stores in localStorage, app stores locally). Appearance (Light/Dark/System) also wired end-to-end.
6. **Sensor-reactive motion**`rememberTiltState()` provides smoothed tilt `Offset(-1..1)`. Uses `TYPE_GAME_ROTATION_VECTOR` (fused, no drift). Auto-disables on battery saver, reduced motion, background, extreme tilt.
**Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object.
@ -80,6 +80,10 @@ Full spec: `DESIGN.md`. Branch: `feature/design-system-v2`.
**KaizenOrb** (`chat/Background.kt`) uses `rememberTiltState()` from `ui/sensor/` instead of inline sensor code. Breath animation uses `Durations.ORB_BREATH` / `ORB_BREATH_STREAMING` from `ui/motion/`.
**Typography** — Inter Variable (`res/font/inter_variable.ttf`, 859 KB, SIL OFL). Full type scale from DESIGN.md §8: `displayLarge` (32sp/W600) through `labelSmall` (11sp/W500). All 9 weight axes registered (W100W900). Negative tracking at display sizes, positive at label sizes. `@OptIn(ExperimentalTextApi::class)` for `FontVariation.Settings`.
**Oklab** (`ui/theme/Oklab.kt`) — Double-precision matrix math for accurate sRGB↔Oklab round-trips. Used by theme picker's Oklab gradient swatches and `generateGradientStops()`. Forward LMS matrix corrected to Björn Ottosson reference.
### Internationalization (i18n)
- **de** (default), **en** — same as web (`messages/de.json`, `messages/en.json`).

View file

@ -7,6 +7,7 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen
@ -14,6 +15,7 @@ import dev.kaizen.app.chat.ChatViewModel
import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.settings.SettingsViewModel
import dev.kaizen.app.settings.AppAppearance
import dev.kaizen.app.ui.theme.KaizenTheme
class MainActivity : ComponentActivity() {
@ -39,7 +41,14 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
)
setContent {
KaizenTheme {
val themeId = settingsViewModel.selectedTheme.collectAsState()
val appearance = settingsViewModel.appearanceMode.collectAsState()
val darkOverride = when (appearance.value) {
AppAppearance.Light -> false
AppAppearance.Dark -> true
AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme()
}
KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) {
// Snapshot-state routing: logging in/out flips session.config and
// recomposes this branch — no NavHost needed for two top-level states.
if (sessionViewModel.isLoggedIn) {

View file

@ -57,6 +57,7 @@ import androidx.compose.ui.unit.sp
import dev.kaizen.app.ui.theme.Amber
import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.Oklab
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
/** Converts polar OKLCH coordinates (Lightness, Chroma, Hue in degrees) to a standard Color */
private fun oklch(l: Float, c: Float, h: Float, alpha: Float = 1.0f): Color {
@ -201,17 +202,17 @@ fun ThemeOptionPill(
val (colorStart, colorEnd) = remember(themeId, isDark) {
if (isDark) {
when (themeId) {
KaizenThemeId.Amber -> oklch(0.76f, 0.14f, 83f) to oklch(0.55f, 0.16f, 83f)
KaizenThemeId.Blue -> oklch(0.68f, 0.18f, 257f) to oklch(0.45f, 0.18f, 257f)
KaizenThemeId.Aurora -> oklch(0.78f, 0.15f, 85f) to oklch(0.45f, 0.18f, 320f)
KaizenThemeId.Mono -> oklch(0.92f, 0f, 255f) to oklch(0.35f, 0f, 255f)
KaizenThemeId.AMBER -> oklch(0.76f, 0.14f, 83f) to oklch(0.55f, 0.16f, 83f)
KaizenThemeId.BLAU -> oklch(0.68f, 0.18f, 257f) to oklch(0.45f, 0.18f, 257f)
KaizenThemeId.AURORA -> oklch(0.78f, 0.15f, 85f) to oklch(0.45f, 0.18f, 320f)
KaizenThemeId.MONO -> oklch(0.92f, 0f, 255f) to oklch(0.35f, 0f, 255f)
}
} else {
when (themeId) {
KaizenThemeId.Amber -> oklch(0.72f, 0.14f, 83f) to oklch(0.85f, 0.15f, 83f)
KaizenThemeId.Blue -> oklch(0.62f, 0.19f, 257f) to oklch(0.78f, 0.15f, 257f)
KaizenThemeId.Aurora -> oklch(0.62f, 0.20f, 320f) to oklch(0.75f, 0.16f, 320f)
KaizenThemeId.Mono -> oklch(0.22f, 0f, 255f) to oklch(0.85f, 0f, 255f)
KaizenThemeId.AMBER -> oklch(0.72f, 0.14f, 83f) to oklch(0.85f, 0.15f, 83f)
KaizenThemeId.BLAU -> oklch(0.62f, 0.19f, 257f) to oklch(0.78f, 0.15f, 257f)
KaizenThemeId.AURORA -> oklch(0.62f, 0.20f, 320f) to oklch(0.75f, 0.16f, 320f)
KaizenThemeId.MONO -> oklch(0.22f, 0f, 255f) to oklch(0.85f, 0f, 255f)
}
}
}
@ -251,7 +252,7 @@ fun ThemeOptionPill(
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = if (themeId == KaizenThemeId.Mono && isDark.not()) Color.White else Color.Black,
tint = if (themeId == KaizenThemeId.MONO && isDark.not()) Color.White else Color.Black,
modifier = Modifier.size(16.dp)
)
}

View file

@ -38,6 +38,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import dev.kaizen.app.R
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -94,119 +97,48 @@ fun SettingsScreen(
Spacer(Modifier.width(16.dp))
Text(
text = "Einstellungen",
color = cs.onBackground,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.settings_title), color = cs.onBackground, fontSize = 24.sp, fontWeight = FontWeight.Bold)
}
// --- SEKTION 1: Erscheinungsbild (Theme & Appearance) ---
Text(
text = "ERSCHEINUNGSBILD",
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_appearance), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp))
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
// Theme Picker Row (Oklab perception space stops)
SettingsOptionRow(
title = "Akzentfarbe",
description = "Wähle das Farbschema der App (Oklab perzeptiv)",
icon = Icons.Rounded.Palette
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
KaizenThemeId.values().forEach { theme ->
ThemeOptionPill(
themeId = theme,
selected = selectedTheme == theme,
onClick = { viewModel.selectTheme(theme) }
)
SettingsOptionRow(title = stringResource(R.string.settings_accent_title), description = stringResource(R.string.settings_accent_desc), icon = Icons.Rounded.Palette) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
KaizenThemeId.entries.forEach { theme ->
ThemeOptionPill(themeId = theme, selected = selectedTheme == theme, onClick = { viewModel.selectTheme(theme) })
}
}
}
Spacer(Modifier.height(12.dp))
// Light / Dark / System Appearance segmented toggle pills
SettingsOptionRow(
title = "Helligkeitsmodus",
description = "Passe das Hell/Dunkel-Erscheinungsbild an",
icon = Icons.Rounded.Settings
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAppearance.values().forEach { mode ->
SettingsOptionRow(title = stringResource(R.string.settings_appearance_title), description = stringResource(R.string.settings_appearance_desc), icon = Icons.Rounded.Settings) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
AppAppearance.entries.forEach { mode ->
val label = when (mode) {
AppAppearance.Light -> "Hell"
AppAppearance.Dark -> "Dunkel"
AppAppearance.System -> "System"
AppAppearance.Light -> stringResource(R.string.settings_appearance_light)
AppAppearance.Dark -> stringResource(R.string.settings_appearance_dark)
AppAppearance.System -> stringResource(R.string.settings_appearance_system)
}
AppearanceTogglePill(
selected = appearanceMode == mode,
label = label,
onClick = { viewModel.selectAppearance(mode) }
)
AppearanceTogglePill(selected = appearanceMode == mode, label = label, onClick = { viewModel.selectAppearance(mode) })
}
}
}
}
// --- SEKTION 2: Profil & Konto ---
Text(
text = "PROFIL & KONTO",
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))
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
// Username editor row
SettingsOptionRow(
title = "Benutzername",
description = "Ändere deinen Anzeigenamen",
icon = Icons.Rounded.Person
) {
SettingsInputCapsule(
value = userName,
onValueChange = { viewModel.updateUserName(it) }
)
SettingsOptionRow(title = stringResource(R.string.settings_name_title), description = stringResource(R.string.settings_name_desc), icon = Icons.Rounded.Person) {
SettingsInputCapsule(value = userName, onValueChange = { viewModel.updateUserName(it) })
}
Spacer(Modifier.height(12.dp))
// Email display row (read-only in this screen view)
SettingsOptionRow(
title = "E-Mail-Adresse",
description = userEmail,
icon = Icons.Rounded.Settings
)
SettingsOptionRow(title = stringResource(R.string.settings_email_title), description = userEmail, icon = Icons.Rounded.Settings)
}
// --- SEKTION 3: KI-Profil & Kontext ---
Text(
text = "KI-PROFIL & KONTEXT",
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_ai), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp))
SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) {
SettingsOptionRow(
title = "System-Prompt & Fakten",
description = "Aktive Regeln: 12, Pinned: 3, Verwendetes Budget: 412 / 1500 Tokens",
icon = Icons.Rounded.AutoAwesome
)
SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome)
}
}
}

View file

@ -1,67 +1,40 @@
package dev.kaizen.app.settings
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
// Available Kaizen UI themes
enum class KaizenThemeId { Amber, Blue, Aurora, Mono }
// Available Light/Dark/System appearance modes
enum class AppAppearance { Light, Dark, System }
/**
* Clean MVVM ViewModel for the Settings module.
*
* Completely decoupled from Android UI classes, making it 100% unit-testable
* on the JVM. Manages the state of the Oklab Theme Picker, selected model,
* and user profile options.
*/
class SettingsViewModel : ViewModel() {
// 1. Reactive State Flow for Theme
private val _selectedTheme = MutableStateFlow(KaizenThemeId.Amber)
private val _selectedTheme = MutableStateFlow(KaizenThemeId.AMBER)
val selectedTheme: StateFlow<KaizenThemeId> = _selectedTheme.asStateFlow()
// 2. Reactive State Flow for Appearance
private val _appearanceMode = MutableStateFlow(AppAppearance.Dark)
private val _appearanceMode = MutableStateFlow(AppAppearance.System)
val appearanceMode: StateFlow<AppAppearance> = _appearanceMode.asStateFlow()
// 3. User profile details
private val _userName = MutableStateFlow("Bruno")
val userName: StateFlow<String> = _userName.asStateFlow()
private val _userEmail = MutableStateFlow("admin@kryptomrx.de")
private val _userEmail = MutableStateFlow("")
val userEmail: StateFlow<String> = _userEmail.asStateFlow()
// 4. Default Chat Model
private val _defaultModel = MutableStateFlow("gemini-2.5-flash")
val defaultModel: StateFlow<String> = _defaultModel.asStateFlow()
/** Selects a new Kaizen UI theme and triggers an Oklab transition stops recalculation */
fun selectTheme(themeId: KaizenThemeId) {
_selectedTheme.value = themeId
}
/** Changes the appearance mode (Light, Dark, or System Sync) */
fun selectAppearance(mode: AppAppearance) {
_appearanceMode.value = mode
}
/** Updates the user's name in profile settings */
fun updateUserName(name: String) {
if (name.isNotBlank()) {
_userName.value = name.trim()
}
if (name.isNotBlank()) _userName.value = name.trim()
}
/** Updates the standard chat model */
fun updateDefaultModel(modelId: String) {
if (modelId.isNotBlank()) {
_defaultModel.value = modelId.trim()
}
fun updateUserEmail(email: String) {
_userEmail.value = email
}
}

View file

@ -2,33 +2,123 @@ package dev.kaizen.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import dev.kaizen.app.R
@OptIn(androidx.compose.ui.text.ExperimentalTextApi::class)
val InterVariable = FontFamily(
Font(
R.font.inter_variable,
weight = FontWeight.W100,
variationSettings = FontVariation.Settings(FontVariation.weight(100)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W200,
variationSettings = FontVariation.Settings(FontVariation.weight(200)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W300,
variationSettings = FontVariation.Settings(FontVariation.weight(300)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W400,
variationSettings = FontVariation.Settings(FontVariation.weight(400)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W500,
variationSettings = FontVariation.Settings(FontVariation.weight(500)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W600,
variationSettings = FontVariation.Settings(FontVariation.weight(600)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W700,
variationSettings = FontVariation.Settings(FontVariation.weight(700)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W800,
variationSettings = FontVariation.Settings(FontVariation.weight(800)),
),
Font(
R.font.inter_variable,
weight = FontWeight.W900,
variationSettings = FontVariation.Settings(FontVariation.weight(900)),
),
)
// Set of Material typography styles to start with
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = InterVariable,
fontWeight = FontWeight.W600,
fontSize = 32.sp,
lineHeight = 38.sp,
letterSpacing = (-0.32).sp,
),
displayMedium = TextStyle(
fontFamily = InterVariable,
fontWeight = FontWeight.W600,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = (-0.12).sp,
),
titleLarge = TextStyle(
fontFamily = InterVariable,
fontWeight = FontWeight.W600,
fontSize = 20.sp,
lineHeight = 26.sp,
),
titleMedium = TextStyle(
fontFamily = InterVariable,
fontWeight = FontWeight.W500,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = 0.05.sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontFamily = InterVariable,
fontWeight = FontWeight.W400,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
letterSpacing = 0.08.sp,
),
bodyMedium = TextStyle(
fontFamily = InterVariable,
fontWeight = FontWeight.W400,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.11.sp,
),
labelLarge = TextStyle(
fontFamily = InterVariable,
fontWeight = FontWeight.W500,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.14.sp,
),
labelMedium = TextStyle(
fontFamily = InterVariable,
fontWeight = FontWeight.W500,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.24.sp,
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontFamily = InterVariable,
fontWeight = FontWeight.W500,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
letterSpacing = 0.33.sp,
),
)

Binary file not shown.

View file

@ -1,67 +1,55 @@
package dev.kaizen.app
import dev.kaizen.app.settings.AppAppearance
import dev.kaizen.app.settings.KaizenThemeId
import dev.kaizen.app.settings.SettingsViewModel
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* High-fidelity JVM Unit Test for the SettingsViewModel.
*
* Verifies that state transitions, user inputs, and theme selections are
* correctly and reactively managed inside the view model under clean MVVM.
*/
class SettingsViewModelTest {
@Test
fun testInitialState() {
val viewModel = SettingsViewModel()
// Assert correct default states
assertEquals(KaizenThemeId.Amber, viewModel.selectedTheme.value)
assertEquals(AppAppearance.Dark, viewModel.appearanceMode.value)
assertEquals(KaizenThemeId.AMBER, viewModel.selectedTheme.value)
assertEquals(AppAppearance.System, viewModel.appearanceMode.value)
assertEquals("Bruno", viewModel.userName.value)
assertEquals("gemini-2.5-flash", viewModel.defaultModel.value)
}
@Test
fun testSelectTheme() {
val viewModel = SettingsViewModel()
viewModel.selectTheme(KaizenThemeId.AURORA)
assertEquals(KaizenThemeId.AURORA, viewModel.selectedTheme.value)
}
// Transition theme to Aurora
viewModel.selectTheme(KaizenThemeId.Aurora)
assertEquals(KaizenThemeId.Aurora, viewModel.selectedTheme.value)
@Test
fun testSelectAllThemes() {
val viewModel = SettingsViewModel()
KaizenThemeId.entries.forEach { id ->
viewModel.selectTheme(id)
assertEquals(id, viewModel.selectedTheme.value)
}
}
@Test
fun testSelectAppearance() {
val viewModel = SettingsViewModel()
// Switch appearance to Light Mode
viewModel.selectAppearance(AppAppearance.Light)
assertEquals(AppAppearance.Light, viewModel.appearanceMode.value)
}
@Test
fun testUpdateUserName() {
val viewModel = SettingsViewModel()
// Update user's name
viewModel.updateUserName(" Giuseppe ") // Should be trimmed
viewModel.updateUserName(" Giuseppe ")
assertEquals("Giuseppe", viewModel.userName.value)
}
@Test
fun testUpdateUserNameBlankIgnored() {
val viewModel = SettingsViewModel()
// Attempting to set an empty/blank name should be ignored
viewModel.updateUserName(" ")
assertEquals("Bruno", viewModel.userName.value) // Remains unchanged
assertEquals("Bruno", viewModel.userName.value)
}
}