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`. 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`. 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()`. 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. 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. **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/`. **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) ### Internationalization (i18n)
- **de** (default), **en** — same as web (`messages/de.json`, `messages/en.json`). - **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.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen 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.SecureStore
import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
import dev.kaizen.app.settings.AppAppearance
import dev.kaizen.app.ui.theme.KaizenTheme import dev.kaizen.app.ui.theme.KaizenTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -39,7 +41,14 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
) )
setContent { 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 // Snapshot-state routing: logging in/out flips session.config and
// recomposes this branch — no NavHost needed for two top-level states. // recomposes this branch — no NavHost needed for two top-level states.
if (sessionViewModel.isLoggedIn) { 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.Amber
import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.AmberDeep
import dev.kaizen.app.ui.theme.Oklab 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 */ /** 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 { 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) { val (colorStart, colorEnd) = remember(themeId, isDark) {
if (isDark) { if (isDark) {
when (themeId) { when (themeId) {
KaizenThemeId.Amber -> oklch(0.76f, 0.14f, 83f) to oklch(0.55f, 0.16f, 83f) 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.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.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.MONO -> oklch(0.92f, 0f, 255f) to oklch(0.35f, 0f, 255f)
} }
} else { } else {
when (themeId) { when (themeId) {
KaizenThemeId.Amber -> oklch(0.72f, 0.14f, 83f) to oklch(0.85f, 0.15f, 83f) 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.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.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.MONO -> oklch(0.22f, 0f, 255f) to oklch(0.85f, 0f, 255f)
} }
} }
} }
@ -251,7 +252,7 @@ fun ThemeOptionPill(
Icon( Icon(
imageVector = Icons.Rounded.Check, imageVector = Icons.Rounded.Check,
contentDescription = null, 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) 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -94,119 +97,48 @@ fun SettingsScreen(
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Text( Text(stringResource(R.string.settings_title), color = cs.onBackground, fontSize = 24.sp, fontWeight = FontWeight.Bold)
text = "Einstellungen",
color = cs.onBackground,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
} }
// --- SEKTION 1: Erscheinungsbild (Theme & Appearance) --- 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))
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)
)
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) { SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
// Theme Picker Row (Oklab perception space stops) SettingsOptionRow(title = stringResource(R.string.settings_accent_title), description = stringResource(R.string.settings_accent_desc), icon = Icons.Rounded.Palette) {
SettingsOptionRow( Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
title = "Akzentfarbe", KaizenThemeId.entries.forEach { theme ->
description = "Wähle das Farbschema der App (Oklab perzeptiv)", ThemeOptionPill(themeId = theme, selected = selectedTheme == theme, onClick = { viewModel.selectTheme(theme) })
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) }
)
} }
} }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
SettingsOptionRow(title = stringResource(R.string.settings_appearance_title), description = stringResource(R.string.settings_appearance_desc), icon = Icons.Rounded.Settings) {
// Light / Dark / System Appearance segmented toggle pills Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
SettingsOptionRow( AppAppearance.entries.forEach { mode ->
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 ->
val label = when (mode) { val label = when (mode) {
AppAppearance.Light -> "Hell" AppAppearance.Light -> stringResource(R.string.settings_appearance_light)
AppAppearance.Dark -> "Dunkel" AppAppearance.Dark -> stringResource(R.string.settings_appearance_dark)
AppAppearance.System -> "System" AppAppearance.System -> stringResource(R.string.settings_appearance_system)
} }
AppearanceTogglePill( AppearanceTogglePill(selected = appearanceMode == mode, label = label, onClick = { viewModel.selectAppearance(mode) })
selected = appearanceMode == mode,
label = label,
onClick = { viewModel.selectAppearance(mode) }
)
} }
} }
} }
} }
// --- SEKTION 2: Profil & Konto --- 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(
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)
)
SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) { SettingsCard(modifier = Modifier.padding(bottom = 24.dp)) {
// Username editor row SettingsOptionRow(title = stringResource(R.string.settings_name_title), description = stringResource(R.string.settings_name_desc), icon = Icons.Rounded.Person) {
SettingsOptionRow( SettingsInputCapsule(value = userName, onValueChange = { viewModel.updateUserName(it) })
title = "Benutzername",
description = "Ändere deinen Anzeigenamen",
icon = Icons.Rounded.Person
) {
SettingsInputCapsule(
value = userName,
onValueChange = { viewModel.updateUserName(it) }
)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
SettingsOptionRow(title = stringResource(R.string.settings_email_title), description = userEmail, icon = Icons.Rounded.Settings)
// Email display row (read-only in this screen view)
SettingsOptionRow(
title = "E-Mail-Adresse",
description = userEmail,
icon = Icons.Rounded.Settings
)
} }
// --- SEKTION 3: KI-Profil & Kontext --- 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))
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)
)
SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) { SettingsCard(modifier = Modifier.padding(bottom = 32.dp)) {
SettingsOptionRow( SettingsOptionRow(title = stringResource(R.string.settings_ai_title), description = "", icon = Icons.Rounded.AutoAwesome)
title = "System-Prompt & Fakten",
description = "Aktive Regeln: 12, Pinned: 3, Verwendetes Budget: 412 / 1500 Tokens",
icon = Icons.Rounded.AutoAwesome
)
} }
} }
} }

View file

@ -1,67 +1,40 @@
package dev.kaizen.app.settings package dev.kaizen.app.settings
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
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
import kotlinx.coroutines.flow.asStateFlow 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 } 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() { 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() val selectedTheme: StateFlow<KaizenThemeId> = _selectedTheme.asStateFlow()
// 2. Reactive State Flow for Appearance private val _appearanceMode = MutableStateFlow(AppAppearance.System)
private val _appearanceMode = MutableStateFlow(AppAppearance.Dark)
val appearanceMode: StateFlow<AppAppearance> = _appearanceMode.asStateFlow() val appearanceMode: StateFlow<AppAppearance> = _appearanceMode.asStateFlow()
// 3. User profile details
private val _userName = MutableStateFlow("Bruno") private val _userName = MutableStateFlow("Bruno")
val userName: StateFlow<String> = _userName.asStateFlow() val userName: StateFlow<String> = _userName.asStateFlow()
private val _userEmail = MutableStateFlow("admin@kryptomrx.de") private val _userEmail = MutableStateFlow("")
val userEmail: StateFlow<String> = _userEmail.asStateFlow() 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) { fun selectTheme(themeId: KaizenThemeId) {
_selectedTheme.value = themeId _selectedTheme.value = themeId
} }
/** Changes the appearance mode (Light, Dark, or System Sync) */
fun selectAppearance(mode: AppAppearance) { fun selectAppearance(mode: AppAppearance) {
_appearanceMode.value = mode _appearanceMode.value = mode
} }
/** Updates the user's name in profile settings */
fun updateUserName(name: String) { fun updateUserName(name: String) {
if (name.isNotBlank()) { if (name.isNotBlank()) _userName.value = name.trim()
_userName.value = name.trim()
}
} }
/** Updates the standard chat model */ fun updateUserEmail(email: String) {
fun updateDefaultModel(modelId: String) { _userEmail.value = email
if (modelId.isNotBlank()) {
_defaultModel.value = modelId.trim()
}
} }
} }

View file

@ -2,33 +2,123 @@ package dev.kaizen.app.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle 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.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp 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( 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( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = InterVariable,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.W400,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.08.sp,
) ),
/* Other default text styles to override bodyMedium = TextStyle(
titleLarge = TextStyle( fontFamily = InterVariable,
fontFamily = FontFamily.Default, fontWeight = FontWeight.W400,
fontWeight = FontWeight.Normal, fontSize = 14.sp,
fontSize = 22.sp, lineHeight = 20.sp,
lineHeight = 28.sp, letterSpacing = 0.11.sp,
letterSpacing = 0.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( labelSmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = InterVariable,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.W500,
fontSize = 11.sp, fontSize = 11.sp,
lineHeight = 16.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 package dev.kaizen.app
import dev.kaizen.app.settings.AppAppearance import dev.kaizen.app.settings.AppAppearance
import dev.kaizen.app.settings.KaizenThemeId
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test 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 { class SettingsViewModelTest {
@Test @Test
fun testInitialState() { fun testInitialState() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
assertEquals(KaizenThemeId.AMBER, viewModel.selectedTheme.value)
// Assert correct default states assertEquals(AppAppearance.System, viewModel.appearanceMode.value)
assertEquals(KaizenThemeId.Amber, viewModel.selectedTheme.value)
assertEquals(AppAppearance.Dark, viewModel.appearanceMode.value)
assertEquals("Bruno", viewModel.userName.value) assertEquals("Bruno", viewModel.userName.value)
assertEquals("gemini-2.5-flash", viewModel.defaultModel.value)
} }
@Test @Test
fun testSelectTheme() { fun testSelectTheme() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
viewModel.selectTheme(KaizenThemeId.AURORA)
assertEquals(KaizenThemeId.AURORA, viewModel.selectedTheme.value)
}
// Transition theme to Aurora @Test
viewModel.selectTheme(KaizenThemeId.Aurora) fun testSelectAllThemes() {
val viewModel = SettingsViewModel()
assertEquals(KaizenThemeId.Aurora, viewModel.selectedTheme.value) KaizenThemeId.entries.forEach { id ->
viewModel.selectTheme(id)
assertEquals(id, viewModel.selectedTheme.value)
}
} }
@Test @Test
fun testSelectAppearance() { fun testSelectAppearance() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
// Switch appearance to Light Mode
viewModel.selectAppearance(AppAppearance.Light) viewModel.selectAppearance(AppAppearance.Light)
assertEquals(AppAppearance.Light, viewModel.appearanceMode.value) assertEquals(AppAppearance.Light, viewModel.appearanceMode.value)
} }
@Test @Test
fun testUpdateUserName() { fun testUpdateUserName() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
viewModel.updateUserName(" Giuseppe ")
// Update user's name
viewModel.updateUserName(" Giuseppe ") // Should be trimmed
assertEquals("Giuseppe", viewModel.userName.value) assertEquals("Giuseppe", viewModel.userName.value)
} }
@Test @Test
fun testUpdateUserNameBlankIgnored() { fun testUpdateUserNameBlankIgnored() {
val viewModel = SettingsViewModel() val viewModel = SettingsViewModel()
// Attempting to set an empty/blank name should be ignored
viewModel.updateUserName(" ") viewModel.updateUserName(" ")
assertEquals("Bruno", viewModel.userName.value)
assertEquals("Bruno", viewModel.userName.value) // Remains unchanged
} }
} }