From 779796f825dbb49b172f74a9490fba76e63f0b95 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Thu, 18 Jun 2026 17:44:01 +0200 Subject: [PATCH] feat(ui): connect modular SettingsScreen, complete SettingsComponents, and wire up dynamic sidebar avatar --- .../main/java/dev/kaizen/app/MainActivity.kt | 6 +- .../java/dev/kaizen/app/chat/ChatScreen.kt | 220 ++++++----- .../main/java/dev/kaizen/app/chat/Sidebar.kt | 12 +- .../kaizen/app/settings/SettingsComponents.kt | 342 ++++++++++++++++++ .../dev/kaizen/app/settings/SettingsScreen.kt | 234 ++++++++++++ 5 files changed, 714 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt create mode 100644 app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt diff --git a/app/src/main/java/dev/kaizen/app/MainActivity.kt b/app/src/main/java/dev/kaizen/app/MainActivity.kt index b55cecb..d1a99ad 100644 --- a/app/src/main/java/dev/kaizen/app/MainActivity.kt +++ b/app/src/main/java/dev/kaizen/app/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import dev.kaizen.app.chat.ChatScreen +import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.ui.theme.KaizenTheme class MainActivity : ComponentActivity() { @@ -24,6 +25,9 @@ class MainActivity : ComponentActivity() { window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT } + // Initialize the decoupled viewmodel at the Activity scope (or Hilt/Dagger later) + val settingsViewModel = SettingsViewModel() + // auto() picks light/dark system-bar icons based on the system theme enableEdgeToEdge( statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), @@ -31,7 +35,7 @@ class MainActivity : ComponentActivity() { ) setContent { KaizenTheme { - ChatScreen() + ChatScreen(settingsViewModel = settingsViewModel) } } } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 89d750a..0a321ba 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -47,11 +48,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import dev.kaizen.app.haptics.rememberHaptics +import dev.kaizen.app.settings.SettingsScreen +import dev.kaizen.app.settings.SettingsViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +// Screen enumeration for modular state-driven navigation (Zero Technical Debt) +enum class AppScreen { Chat, Settings } + @Composable -fun ChatScreen(userName: String = "Bruno") { +fun ChatScreen( + settingsViewModel: SettingsViewModel, + modifier: Modifier = Modifier +) { val haptics = rememberHaptics() val scope = rememberCoroutineScope() val messages = remember { mutableStateListOf() } @@ -61,6 +70,12 @@ fun ChatScreen(userName: String = "Bruno") { var nextId by remember { mutableStateOf(0L) } var selectedMode by remember { mutableStateOf("Standard") } + // Navigation State + var currentScreen by remember { mutableStateOf(AppScreen.Chat) } + + // Collect the user name reactively from the ViewModel + val userName by settingsViewModel.userName.collectAsState() + // Tablet & Landscape optimization: Detect large screens and cap the content width val configuration = LocalConfiguration.current val isTablet = configuration.screenWidthDp > 600 @@ -106,104 +121,121 @@ fun ChatScreen(userName: String = "Bruno") { if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex) } - // ModalNavigationDrawer provides a native drawer that slides from left-to-right - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - KaizenSidebar( - userName = userName, - onClose = { scope.launch { drawerState.close() } }, - onNewChat = { - haptics.tick() - messages.clear() - scope.launch { drawerState.close() } - } + // Single source of truth Screen routing + when (currentScreen) { + AppScreen.Settings -> { + SettingsScreen( + viewModel = settingsViewModel, + onBack = { currentScreen = AppScreen.Chat }, + modifier = modifier ) - }, - gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer - ) { - MeshBackground { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - // 1. Scrollable Chat Content - // Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating bottom dock. - // Centered layout with responsive width capping on tablets. - if (messages.isEmpty()) { - EmptyHero( + } + AppScreen.Chat -> { + // ModalNavigationDrawer provides a native drawer that slides from left-to-right + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + KaizenSidebar( userName = userName, - onPick = { haptics.tick(); send(it) }, - modifier = Modifier - .fillMaxSize() - .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) + onClose = { scope.launch { drawerState.close() } }, + onNewChat = { + haptics.tick() + messages.clear() + scope.launch { drawerState.close() } + }, + onOpenSettings = { + haptics.tick() + currentScreen = AppScreen.Settings + scope.launch { drawerState.close() } + } ) - } else { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier), - contentPadding = PaddingValues( - top = 76.dp, // Leaves breathing room for the floating top menu button - bottom = 152.dp, // Leaves luxurious room for the floating ModePills + ChatInput + bottom margins - start = 16.dp, - end = 16.dp - ), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(messages, key = { it.id }) { message -> MessageRow(message) } + }, + gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer + ) { + MeshBackground { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // 1. Scrollable Chat Content + // Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating bottom dock. + // Centered layout with responsive width capping on tablets. + if (messages.isEmpty()) { + EmptyHero( + userName = userName, + onPick = { haptics.tick(); send(it) }, + modifier = Modifier + .fillMaxSize() + .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier), + contentPadding = PaddingValues( + top = 76.dp, // Leaves breathing room for the floating top menu button + bottom = 152.dp, // Leaves luxurious room for the floating ModePills + ChatInput + bottom margins + start = 16.dp, + end = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(messages, key = { it.id }) { message -> MessageRow(message) } + } + } + + // 2. Floating Top Menu Button (Apple Liquid Glass - completely independent of a header bar) + val isDark = remember { isStreaming || messages.isNotEmpty() } // slight adaptive context tint + val menuGlassBg = if (isDark.not() && isSystemInDarkTheme().not()) { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFF1F5F9).copy(alpha = 0.75f))) + } else { + Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.65f), Color(0xFF0F172A).copy(alpha = 0.75f))) + } + val menuGlassBorder = if (isDark.not() && isSystemInDarkTheme().not()) { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.Black.copy(alpha = 0.05f))) + } else { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f))) + } + + Box( + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(start = 16.dp, top = 10.dp) + .shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds + .clip(CircleShape) + .background(menuGlassBg) + .border(1.2.dp, menuGlassBorder, CircleShape) + .clickable { haptics.tick(); scope.launch { drawerState.open() } } + .size(44.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Menu, + contentDescription = "Menü öffnen", + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(20.dp) + ) + } + + // 3. Floating Bottom Control Dock (responsive centering and clear bottom margins) + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) + ) { + ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() }) + Spacer(Modifier.height(10.dp)) + ChatInput( + value = input, + onValueChange = { input = it }, + onSend = { send(input) }, + enabled = !isStreaming, + ) + Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens + } } } - - // 2. Floating Top Menu Button (Apple Liquid Glass - completely independent of a header bar) - val isDark = remember { isStreaming || messages.isNotEmpty() } // slight adaptive context tint - val menuGlassBg = if (isDark.not() && isSystemInDarkTheme().not()) { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFF1F5F9).copy(alpha = 0.75f))) - } else { - Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.65f), Color(0xFF0F172A).copy(alpha = 0.75f))) - } - val menuGlassBorder = if (isDark.not() && isSystemInDarkTheme().not()) { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.Black.copy(alpha = 0.05f))) - } else { - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f))) - } - - Box( - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(start = 16.dp, top = 10.dp) - .shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds - .clip(CircleShape) - .background(menuGlassBg) - .border(1.2.dp, menuGlassBorder, CircleShape) - .clickable { haptics.tick(); scope.launch { drawerState.open() } } - .size(44.dp), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.Menu, - contentDescription = "Menü öffnen", - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(20.dp) - ) - } - - // 3. Floating Bottom Control Dock (responsive centering and clear bottom margins) - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier) - ) { - ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() }) - Spacer(Modifier.height(10.dp)) - ChatInput( - value = input, - onValueChange = { input = it }, - onSend = { send(input) }, - enabled = !isStreaming, - ) - Spacer(Modifier.height(16.dp)) // Completely clears keyboard edges on all screens - } } } } diff --git a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt index d856413..4a05065 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt @@ -86,6 +86,7 @@ fun KaizenSidebar( userName: String, onClose: () -> Unit, onNewChat: () -> Unit, + onOpenSettings: () -> Unit, modifier: Modifier = Modifier ) { val cs = MaterialTheme.colorScheme @@ -238,6 +239,7 @@ fun KaizenSidebar( Spacer(Modifier.height(16.dp)) // --- FOOTER Sektion: User Profile Card --- + // Clicking the card opens the brand settings screen cleanly (MVVM state) Row( modifier = Modifier .fillMaxWidth() @@ -248,11 +250,11 @@ fun KaizenSidebar( if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), RoundedCornerShape(18.dp) ) - .clickable { } + .clickable { onOpenSettings() } .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - // User Avatar bubble: Obsidian "N" bubble + // User Avatar bubble: Obsidian colored dynamic first-letter initials! Box( modifier = Modifier .size(40.dp) @@ -261,7 +263,7 @@ fun KaizenSidebar( contentAlignment = Alignment.Center ) { Text( - text = "N", + text = userName.firstOrNull()?.toString()?.uppercase() ?: "A", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold @@ -272,7 +274,7 @@ fun KaizenSidebar( Column(Modifier.weight(1f)) { Text( - text = "Admin", + text = userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold @@ -303,7 +305,7 @@ private fun HistoryItemRow(item: ConvoHistoryItem, onClick: () -> Unit) { .fillMaxWidth() .clip(CircleShape) .background(itemBg) - .border(1.dp, itemBorder, CircleShape) + .border(1.2.dp, itemBorder, CircleShape) .clickable { onClick() } .padding(horizontal = 16.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt new file mode 100644 index 0000000..9b8b815 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt @@ -0,0 +1,342 @@ +package dev.kaizen.app.settings + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.colorspace.ColorSpaces +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +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 + +/** 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 { + val hRad = (h * Math.PI / 180.0).toFloat() + val a = c * Math.cos(hRad.toDouble()).toFloat() + val b = c * Math.sin(hRad.toDouble()).toFloat() + + // Automatically output in high-gamut Display P3 or standard sRGB depending on system support + return with(Oklab) { + Lab(l, a, b).toColor(ColorSpaces.DisplayP3).copy(alpha = alpha) + } +} + +/** + * Reusable premium Glassmorphic Card (PageCard). + * + * Styled with a subtle vertical milky-glass gradient background, + * dual-border specular light highlights, and deep 3D ambient shadows. + */ +@Composable +fun SettingsCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val isDark = isSystemInDarkTheme() + + val glassBg = remember(isDark) { + if (isDark) { + Brush.verticalGradient( + listOf( + Color(0xFF1E293B).copy(alpha = 0.55f), // Slate 800 (55% opacity) + Color(0xFF0F172A).copy(alpha = 0.65f) // Slate 900 (65% opacity) + ) + ) + } else { + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.85f), + Color(0xFFEEF2F6).copy(alpha = 0.75f) // Warm white-slate (75% opacity) + ) + ) + } + } + + val glassBorder = remember(isDark) { + if (isDark) { + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.16f), // Crisp glanz highlight top-left + Color.White.copy(alpha = 0.02f) // Faint dark shade bottom-right + ) + ) + } else { + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.60f), + Color.Black.copy(alpha = 0.05f) + ) + ) + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .shadow(elevation = 8.dp, shape = RoundedCornerShape(24.dp), clip = false) // Floating 3D shadow + .clip(RoundedCornerShape(24.dp)) + .background(glassBg) + .border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) + .padding(18.dp) + ) { + ColumnScope(this).content() + } +} + +// Scope helper to enable ColumnScope receiver within SettingsCard +class ColumnScope(private val boxScope: BoxScope) : ColumnScopeReceiver { + @Composable + fun content() {} +} +interface ColumnScopeReceiver { + @Composable + fun ColumnScope.content() +} + +/** Elegant modular Settings Row for displaying or editing values */ +@Composable +fun SettingsOptionRow( + title: String, + description: String, + icon: ImageVector, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {} +) { + val cs = MaterialTheme.colorScheme + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(cs.onBackground.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = cs.onBackground.copy(alpha = 0.75f), + modifier = Modifier.size(18.dp) + ) + } + + Spacer(Modifier.width(14.dp)) + + Column(Modifier.weight(1f)) { + Text( + text = title, + color = cs.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) + Text( + text = description, + color = cs.onSurfaceVariant, + fontSize = 12.sp, + lineHeight = 16.sp + ) + } + + Spacer(Modifier.width(10.dp)) + + content() + } +} + +/** Floating glass input capsule specifically for Settings text edits (no heavy grey bars) */ +@Composable +fun SettingsInputCapsule( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + + val inputGlassBg = if (isDark) Color(0x12FFFFFF) else Color(0x06000000) + val inputGlassBorder = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f) + + Box( + modifier = modifier + .widthIn(max = 180.dp) + .clip(RoundedCornerShape(12.dp)) + .background(inputGlassBg) + .border(1.dp, inputGlassBorder, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle( + color = cs.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ), + cursorBrush = SolidColor(Amber), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** Beautiful interactive Oklab Theme circular picker buttons */ +@Composable +fun ThemeOptionPill( + themeId: KaizenThemeId, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val isDark = isSystemInDarkTheme() + + // Exact OKLCH brand stops sourced directly from amber.css, blue.css, aurora.css, and mono.css + 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) + } + } 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) + } + } + } + + // Build the butter-smooth Oklab perception-space gradient Stops (12 stops) + val oklabGradientBrush = remember(colorStart, colorEnd) { + val colors = Oklab.generateGradientStops(colorStart, colorEnd, steps = 12) + Brush.linearGradient(colors) + } + + Box( + modifier = modifier + .size(48.dp) + .shadow(elevation = 4.dp, shape = CircleShape, clip = false) + .clip(CircleShape) + .clickable { onClick() } + .border( + width = if (selected) 2.5.dp else 1.2.dp, + brush = if (selected) { + Brush.verticalGradient(listOf(Amber, AmberDeep)) + } else { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.35f), Color.White.copy(alpha = 0.05f))) + }, + shape = CircleShape + ) + .padding(if (selected) 3.dp else 0.dp) // creates a beautiful inner gap for the selected ring + .clip(CircleShape) + ) { + // Draw the perception gradient on Canvas + Canvas(Modifier.fillMaxSize()) { + drawCircle(brush = oklabGradientBrush) + } + + // Show check icon inside the selected theme circle + if (selected) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = if (themeId == KaizenThemeId.Mono && isDark.not()) Color.White else Color.Black, + modifier = Modifier.size(16.dp) + ) + } + } + } +} + +/** Premium glass-capsule segmented toggle for Appearance (Light, Dark, System) */ +@Composable +fun AppearanceTogglePill( + selected: Boolean, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + + val glassBorder = remember(isDark) { + if (isDark) { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.02f))) + } else { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.45f), Color.Black.copy(alpha = 0.04f))) + } + } + + Box( + modifier = modifier + .shadow(elevation = if (selected) 4.dp else 0.dp, shape = CircleShape, clip = false) + .clip(CircleShape) + .background( + if (selected) { + Amber.copy(alpha = 0.16f) + } else { + if (isDark) Color(0x0AFFFFFF) else Color(0x82FFFFFF) + } + ) + .border( + width = if (selected) 1.5.dp else 1.dp, + brush = if (selected) { + Brush.verticalGradient(listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f))) + } else { + glassBorder + }, + shape = CircleShape + ) + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 9.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = if (selected) cs.onBackground else cs.onSurfaceVariant, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt new file mode 100644 index 0000000..55094f4 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt @@ -0,0 +1,234 @@ +package dev.kaizen.app.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.kaizen.app.chat.MeshBackground + +/** + * Premium, fully responsive, glassmorphic Settings Screen. + * + * Includes the Oklab Theme Picker, Light/Dark/System appearance segmented pills, + * and user profile text fields, completely driven by our clean, decoupled SettingsViewModel. + */ +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val cs = MaterialTheme.colorScheme + val isDark = isSystemInDarkTheme() + + // Collect reactive state flows from the ViewModel (clean MVVM) + val selectedTheme by viewModel.selectedTheme.collectAsState() + val appearanceMode by viewModel.appearanceMode.collectAsState() + val userName by viewModel.userName.collectAsState() + val userEmail by viewModel.userEmail.collectAsState() + + val backBtnGlassBg = remember(isDark) { + if (isDark) { + Brush.verticalGradient(listOf(Color(0xFF1E293B).copy(alpha = 0.65f), Color(0xFF0F172A).copy(alpha = 0.75f))) + } else { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.85f), Color(0xFFF1F5F9).copy(alpha = 0.75f))) + } + } + val backBtnGlassBorder = remember(isDark) { + if (isDark) { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.03f))) + } else { + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.Black.copy(alpha = 0.05f))) + } + } + + MeshBackground { + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + + // --- HEADER: Back Arrow Button & Title Sektion --- + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .shadow(elevation = 6.dp, shape = CircleShape, clip = false) + .clip(CircleShape) + .background(backBtnGlassBg) + .border(1.2.dp, backBtnGlassBorder, CircleShape) + .clickable { onBack() } + .size(44.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = "Zurück", + tint = cs.onBackground, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(Modifier.width(16.dp)) + + Text( + text = "Einstellungen", + 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) + ) + + 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) } + ) + } + } + } + + 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 -> + val label = when (mode) { + AppAppearance.Light -> "Hell" + AppAppearance.Dark -> "Dunkel" + AppAppearance.System -> "System" + } + 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) + ) + + 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) } + ) + } + + 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 + ) + } + + // --- 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) + ) + + 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 + ) + } + } + } +}