feat(ui): connect modular SettingsScreen, complete SettingsComponents, and wire up dynamic sidebar avatar
This commit is contained in:
parent
d0cb89cf6e
commit
779796f825
5 changed files with 714 additions and 100 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Message>() }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
342
app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt
Normal file
342
app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt
Normal file
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
234
app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt
Normal file
234
app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt
Normal file
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue