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:
parent
f83d4b4e17
commit
515a71efb5
8 changed files with 181 additions and 184 deletions
|
|
@ -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 2–4 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`.
|
4. **Multi-layer shadows** — `ShadowStack` with 2–4 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 (W100–W900). 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`).
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
),
|
||||||
*/
|
)
|
||||||
)
|
|
||||||
|
|
|
||||||
BIN
app/src/main/res/font/inter_variable.ttf
Normal file
BIN
app/src/main/res/font/inter_variable.ttf
Normal file
Binary file not shown.
|
|
@ -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)
|
||||||
// Transition theme to Aurora
|
assertEquals(KaizenThemeId.AURORA, viewModel.selectedTheme.value)
|
||||||
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
|
@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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue