feat: migrate all UI components to design system v2

- Sidebar: GlassSurface + KaizenShapes (lg, sm, md, pill, circle)
- ChatInput: GlassSurface with input tier opacity
- ChatScreen: menu button uses GlassSurface, error banner uses KaizenShapes
- EmptyHero: suggestion chips removed on mobile (DESIGN.md §11.1)
- LoginScreen: GlassFields use KaizenShapes.lg
- ModelSheet: KaizenShapes (xl, md, xs, circle)
- SettingsCard: GlassSurface with card tier opacity
- SettingsScreen: back button uses GlassSurface
- SettingsComponents: all CircleShape/RoundedCornerShape → KaizenShapes
- Markdown: copy button uses KaizenShapes.circle
- GlassSurface: removed misleading Modifier.blur (Android has no backdrop-filter)
- TypingDots: uses motion duration tokens
- Fixed deprecated ArrowBack icon → AutoMirrored variant
This commit is contained in:
Bruno Deanoz 2026-06-21 14:50:11 +02:00
parent 4d71475995
commit 94c0f85fb7
9 changed files with 256 additions and 726 deletions

View file

@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -178,10 +178,10 @@ private fun GlassField(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.widthIn(max = 420.dp) .widthIn(max = 420.dp)
.shadow(elevation = 6.dp, shape = RoundedCornerShape(20.dp), clip = false) .shadow(elevation = 6.dp, shape = KaizenShapes.lg, clip = false)
.clip(RoundedCornerShape(20.dp)) .clip(KaizenShapes.lg)
.background(glassBg) .background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(20.dp)) .border(1.2.dp, glassBorder, KaizenShapes.lg)
.heightIn(min = 54.dp) .heightIn(min = 54.dp)
.padding(horizontal = 18.dp, vertical = 16.dp), .padding(horizontal = 18.dp, vertical = 16.dp),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
@ -216,8 +216,8 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit
.fillMaxWidth() .fillMaxWidth()
.widthIn(max = 420.dp) .widthIn(max = 420.dp)
.heightIn(min = 54.dp) .heightIn(min = 54.dp)
.shadow(elevation = 8.dp, shape = RoundedCornerShape(20.dp), clip = false) .shadow(elevation = 8.dp, shape = KaizenShapes.lg, clip = false)
.clip(RoundedCornerShape(20.dp)) .clip(KaizenShapes.lg)
.then(fill) .then(fill)
.clickable(enabled = enabled, onClick = onClick) .clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 16.dp), .padding(vertical = 16.dp),

View file

@ -19,24 +19,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -53,7 +48,6 @@ 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.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -61,21 +55,23 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
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
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.motion.Durations
import dev.kaizen.app.ui.shape.KaizenShapes
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.OnAmber import dev.kaizen.app.ui.theme.OnAmber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlin.OptIn
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.AudioFile
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Code
@ -98,58 +94,23 @@ import java.util.concurrent.TimeUnit
@Composable @Composable
fun KaizenHeader(modifier: Modifier = Modifier) { fun KaizenHeader(modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() GlassSurface(
modifier = modifier
val glassBg = remember(isDark) {
if (isDark) {
Brush.verticalGradient(
listOf(
Color(0xFF1E293B).copy(alpha = 0.65f), // Slate 800 with 65% opacity
Color(0xFF0F172A).copy(alpha = 0.75f) // Slate 900 with 75% opacity
)
)
} else {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.85f),
Color(0xFFF1F5F9).copy(alpha = 0.75f) // Slate 100 with 75% opacity
)
)
}
}
val glassBorder = remember(isDark) {
if (isDark) {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.16f), // Specular light highlight on top
Color.White.copy(alpha = 0.03f) // Subtle dark fade on bottom
)
)
} else {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.55f),
Color.Black.copy(alpha = 0.05f)
)
)
}
}
Row(
modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp) .padding(horizontal = 16.dp, vertical = 10.dp),
.clip(RoundedCornerShape(24.dp)) shape = KaizenShapes.lg,
.background(glassBg) shadowStack = KaizenShadows.level1,
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) cornerRadius = 24.dp,
.padding(horizontal = 16.dp, vertical = 12.dp), ) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
KaizenOrb(24.dp) KaizenOrb(24.dp)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
} }
}
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@ -161,11 +122,7 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN)) now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
} }
// Dynamic keyboard visibility check (Google Gemini style) // DESIGN.md §11.1: no suggestion chips on mobile — redundant with mode pills
// When the soft keyboard is visible, we completely hide the Suggestion Pills
// to give the user maximum typing space and keep the layout extremely clean.
val isKeyboardOpen = WindowInsets.isImeVisible
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -173,7 +130,7 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(Modifier.height(48.dp)) // Clean native layout, reduced from 88.dp as there is no top bar Spacer(Modifier.height(48.dp))
KaizenOrb(104.dp) KaizenOrb(104.dp)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp) Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
@ -181,56 +138,7 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
Text("Was liegt an?", color = cs.onBackground, fontSize = 32.sp, fontWeight = FontWeight.Bold) Text("Was liegt an?", color = cs.onBackground, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp) Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp)
Spacer(Modifier.height(142.dp))
if (!isKeyboardOpen) {
Spacer(Modifier.height(32.dp))
SuggestionPills(onPick)
}
// Dynamically shrink the bottom spacer when the keyboard is open.
// This ensures the remaining elements fit perfectly into the space above the keyboard
// without causing the column to exceed the screen height and trigger an automatic scroll.
// As a result, the greeting remains perfectly stationary and visible while typing!
val bottomSpacerHeight = if (isKeyboardOpen) 72.dp else 142.dp
Spacer(Modifier.height(bottomSpacerHeight))
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SuggestionPills(onPick: (String) -> Unit) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
// Highly responsive 3D floating glass capsules (iOS 26 / Apple Liquid Glass style)
val glassBg = if (isDark) cs.surface.copy(alpha = 0.45f) else cs.surface.copy(alpha = 0.88f)
val glassBorder = if (isDark) {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.16f), Color.White.copy(alpha = 0.02f)))
} else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.65f), Color.Black.copy(alpha = 0.05f)))
}
FlowRow(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
suggestions.forEach { suggestion ->
Row(
Modifier
.shadow(elevation = 3.dp, shape = CircleShape, clip = false) // Ambient 3D shadow for floating look
.clip(CircleShape)
.background(glassBg)
.border(1.2.dp, glassBorder, CircleShape)
.clickable { onPick(suggestion.prompt) }
.padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(suggestion.icon, null, tint = cs.primary, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(suggestion.label, color = cs.onBackground, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
} }
} }
@ -240,21 +148,11 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val glassBorderInactive = remember(isDark) { val glassBorderInactive = remember(isDark) {
if (isDark) { if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.02f)))
Brush.verticalGradient( else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.45f), Color.Black.copy(alpha = 0.04f)))
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))
)
} }
}
val glassBorderActive = remember(isDark) { val glassBorderActive = remember(isDark) {
Brush.verticalGradient( Brush.verticalGradient(listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f)))
listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f))
)
} }
Row( Row(
@ -266,36 +164,22 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
) { ) {
chatModes.forEach { mode -> chatModes.forEach { mode ->
val active = mode.label == selected val active = mode.label == selected
val backgroundFill = if (active) { val backgroundFill = if (active) Amber.copy(alpha = 0.16f)
Amber.copy(alpha = 0.16f) else if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
} else {
if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
}
val borderBrush = if (active) glassBorderActive else glassBorderInactive val borderBrush = if (active) glassBorderActive else glassBorderInactive
Row( Row(
Modifier Modifier
.shadow(elevation = 2.dp, shape = CircleShape, clip = false) // Floating tabs .clip(KaizenShapes.pill)
.clip(CircleShape)
.background(backgroundFill) .background(backgroundFill)
.border(1.2.dp, borderBrush, CircleShape) .border(1.2.dp, borderBrush, KaizenShapes.pill)
.clickable { onSelect(mode.label) } .clickable { onSelect(mode.label) }
.padding(horizontal = 13.dp, vertical = 8.dp), .padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(mode.icon, null, tint = if (active) Amber else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
mode.icon,
null,
tint = if (active) Amber else cs.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text( Text(mode.label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium)
mode.label,
color = if (active) cs.onBackground else cs.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
} }
} }
} }
@ -305,43 +189,27 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
fun MessageRow(message: Message) { fun MessageRow(message: Message) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val hasAttachments = message.attachments.isNotEmpty() val hasAttachments = message.attachments.isNotEmpty()
if (message.role == Role.User) { if (message.role == Role.User) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp) val shape = KaizenShapes.lg
val borderBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f))) }
val borderBrush = remember { val bgBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f))) }
Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f)))
}
val bgBrush = remember {
Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f)))
}
Box( Box(
Modifier Modifier
.widthIn(max = 300.dp) .widthIn(max = 300.dp)
.shadow(elevation = 3.dp, shape = shape, clip = false)
.clip(shape) .clip(shape)
.background(bgBrush) .background(bgBrush)
.border(1.2.dp, borderBrush, shape), .border(1.2.dp, borderBrush, shape),
) { ) {
Column { Column {
if (hasAttachments) { if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
AttachmentGrid(message.attachments, Modifier.padding(6.dp))
}
if (message.content.isNotBlank()) { if (message.content.isNotBlank()) {
Text( Text(
message.content, message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp,
color = cs.onBackground, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 11.dp, bottom = 11.dp),
fontSize = 16.sp,
lineHeight = 22.sp,
modifier = Modifier.padding(
start = 16.dp, end = 16.dp,
top = if (hasAttachments) 4.dp else 11.dp,
bottom = 11.dp,
),
) )
} }
} }
@ -357,11 +225,8 @@ fun MessageRow(message: Message) {
AttachmentGrid(message.attachments) AttachmentGrid(message.attachments)
if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp)) if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp))
} }
if (message.thinking) { if (message.thinking) TypingDots()
TypingDots() else MarkdownText(text = message.content, streaming = message.streaming)
} else {
MarkdownText(text = message.content, streaming = message.streaming)
}
} }
} }
} }
@ -375,26 +240,18 @@ private fun TypingDots() {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
repeat(3) { i -> repeat(3) { i ->
val alpha by transition.animateFloat( val alpha by transition.animateFloat(
initialValue = 0.25f, initialValue = 0.25f, targetValue = 1f,
targetValue = 1f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
tween(600, delayMillis = i * 160, easing = FastOutSlowInEasing), tween(Durations.THINKING_DOT_CYCLE, delayMillis = i * Durations.THINKING_DOT_PHASE_OFFSET, easing = FastOutSlowInEasing),
RepeatMode.Reverse, RepeatMode.Reverse,
), ),
label = "dot$i", label = "dot$i",
) )
Box( Box(Modifier.padding(end = 5.dp).size(7.dp).clip(KaizenShapes.circle).background(cs.onSurfaceVariant.copy(alpha = alpha)))
Modifier
.padding(end = 5.dp)
.size(7.dp)
.clip(CircleShape)
.background(cs.onSurfaceVariant.copy(alpha = alpha)),
)
} }
} }
} }
/** A file pending upload or already uploaded, shown as a preview chip above the input. */
data class PendingFile( data class PendingFile(
val id: String = java.util.UUID.randomUUID().toString(), val id: String = java.util.UUID.randomUUID().toString(),
val name: String, val name: String,
@ -419,106 +276,49 @@ fun ChatInput(
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val glassBg = remember(isDark) { GlassSurface(
if (isDark) { modifier = modifier
Brush.verticalGradient(
listOf(
Color(0xFF1E293B).copy(alpha = 0.72f), // Slate 800 translucent
Color(0xFF0F172A).copy(alpha = 0.85f) // Slate 900 translucent
)
)
} else {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.90f),
Color(0xFFEEF2F6).copy(alpha = 0.80f)
)
)
}
}
val glassBorder = remember(isDark) {
if (isDark) {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.18f),
Color.White.copy(alpha = 0.04f)
)
)
} else {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.60f),
Color.Black.copy(alpha = 0.06f)
)
)
}
}
Column(
modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp),
.shadow(elevation = 8.dp, shape = RoundedCornerShape(28.dp), clip = false) shape = KaizenShapes.xl,
.clip(RoundedCornerShape(28.dp)) tintAlpha = GlassTiers.input(isDark),
.background(glassBg) shadowStack = KaizenShadows.level2,
.border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)), cornerRadius = 28.dp,
) { ) {
Column {
if (pendingFiles.isNotEmpty()) { if (pendingFiles.isNotEmpty()) {
Row( Row(
Modifier Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp),
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
pendingFiles.forEach { pf -> pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) })
} }
} }
} Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Row( Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onAddClick),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Rounded.Add, "Anhang", tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) Icon(Icons.Rounded.Add, "Anhang", tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
} }
Box( Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) {
Modifier if (value.isEmpty()) Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp)
.weight(1f)
.heightIn(min = 36.dp)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty()) {
Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp)
}
BasicTextField( BasicTextField(
value = value, value = value, onValueChange = onValueChange,
onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(Amber), cursorBrush = SolidColor(Amber), maxLines = 6, modifier = Modifier.fillMaxWidth(),
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
) )
} }
GhostIconButton(Icons.Rounded.Call, "Anruf") GhostIconButton(Icons.Rounded.Call, "Anruf")
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
&& pendingFiles.none { it.uploading }
SendButton(enabled = canSend, onClick = onSend) SendButton(enabled = canSend, onClick = onSend)
} }
} }
}
} }
@Composable @Composable
private fun GhostIconButton(icon: ImageVector, contentDescription: String) { private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
Box(Modifier.size(40.dp).clip(CircleShape), contentAlignment = Alignment.Center) { Box(Modifier.size(40.dp).clip(KaizenShapes.circle), contentAlignment = Alignment.Center) {
Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp)) Icon(icon, contentDescription, tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
} }
} }
@ -530,19 +330,9 @@ private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) {
val bg = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) val bg = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
val isImage = pf.mimeType.startsWith("image/") val isImage = pf.mimeType.startsWith("image/")
Box( Box(Modifier.size(64.dp).clip(KaizenShapes.xs).background(bg), contentAlignment = Alignment.Center) {
Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp))
.background(bg),
contentAlignment = Alignment.Center,
) {
if (isImage && pf.uploaded != null) { if (isImage && pf.uploaded != null) {
NetworkImage( NetworkImage(url = pf.uploaded.url, contentDescription = pf.name, modifier = Modifier.fillMaxSize().clip(KaizenShapes.xs))
url = pf.uploaded.url,
contentDescription = pf.name,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(10.dp)),
)
} else { } else {
val icon = when { val icon = when {
pf.mimeType.startsWith("image/") -> Icons.Rounded.Image pf.mimeType.startsWith("image/") -> Icons.Rounded.Image
@ -553,46 +343,21 @@ private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) {
} }
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp)) Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text( Text(pf.name, color = cs.onSurfaceVariant, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 4.dp))
pf.name,
color = cs.onSurfaceVariant,
fontSize = 9.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp),
)
} }
} }
if (pf.uploading) { if (pf.uploading) {
Box( Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) {
Modifier.fillMaxSize() androidx.compose.material3.CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = Color.White,
)
} }
} }
if (pf.error != null) { if (pf.error != null) {
Box( Box(Modifier.fillMaxSize().background(Color(0xFFDC2626).copy(alpha = 0.3f)), contentAlignment = Alignment.Center) {
Modifier.fillMaxSize()
.background(Color(0xFFDC2626).copy(alpha = 0.3f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Rounded.Close, "Fehler", tint = Color.White, modifier = Modifier.size(18.dp)) Icon(Icons.Rounded.Close, "Fehler", tint = Color.White, modifier = Modifier.size(18.dp))
} }
} }
Box( Box(
Modifier Modifier.align(Alignment.TopEnd).padding(2.dp).size(18.dp).clip(KaizenShapes.circle).background(Color.Black.copy(alpha = 0.5f)).clickable(onClick = onRemove),
.align(Alignment.TopEnd)
.padding(2.dp)
.size(18.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
.clickable(onClick = onRemove),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon(Icons.Rounded.Close, "Entfernen", tint = Color.White, modifier = Modifier.size(12.dp)) Icon(Icons.Rounded.Close, "Entfernen", tint = Color.White, modifier = Modifier.size(12.dp))
@ -606,18 +371,12 @@ private fun SendButton(enabled: Boolean, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send") val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send")
val fill = if (enabled) { val fill = if (enabled) Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep))) else Modifier.background(cs.surface)
} else {
Modifier.background(cs.surface)
}
Box( Box(
Modifier Modifier.size(44.dp).scale(pressScale).clip(KaizenShapes.circle).then(fill)
.size(44.dp) .border(1.dp, cs.outline, KaizenShapes.circle)
.scale(pressScale)
.clip(CircleShape)
.then(fill)
.border(1.dp, cs.outline, CircleShape)
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick), .clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -631,10 +390,7 @@ private object NetworkImageCache {
private val cache = object : LinkedHashMap<String, ImageBitmap>(32, 0.75f, true) { private val cache = object : LinkedHashMap<String, ImageBitmap>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = size > 50 override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = size > 50
} }
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) { suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) {
synchronized(cache) { cache[url] }?.let { return@withContext it } synchronized(cache) { cache[url] }?.let { return@withContext it }
@ -647,8 +403,7 @@ private object NetworkImageCache {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe) BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe)
val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536) val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536)
val opts = BitmapFactory.Options().apply { inSampleSize = scale } val opts = BitmapFactory.Options().apply { inSampleSize = scale }
val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null
?: return@withContext null
bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } } bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } }
} }
} catch (_: Exception) { null } } catch (_: Exception) { null }
@ -656,31 +411,14 @@ private object NetworkImageCache {
} }
@Composable @Composable
private fun NetworkImage( private fun NetworkImage(url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop) {
url: String,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
var bitmap by remember(url) { mutableStateOf<ImageBitmap?>(null) } var bitmap by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) } LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) }
val bmp = bitmap val bmp = bitmap
if (bmp != null) { if (bmp != null) {
Image( Image(bitmap = bmp, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale)
bitmap = bmp,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
} else { } else {
Box( Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)))
modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)
} }
} }
@ -689,17 +427,9 @@ fun AttachmentGrid(attachments: List<Attachment>, modifier: Modifier = Modifier)
if (attachments.isEmpty()) return if (attachments.isEmpty()) return
val images = attachments.filter { it.kind == "image" } val images = attachments.filter { it.kind == "image" }
val others = attachments.filter { it.kind != "image" } val others = attachments.filter { it.kind != "image" }
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
images.forEach { att -> images.forEach { att ->
NetworkImage( NetworkImage(url = att.url, contentDescription = att.name, modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp).clip(KaizenShapes.xs))
url = att.url,
contentDescription = att.name,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
.clip(RoundedCornerShape(10.dp)),
)
} }
others.forEach { att -> FileChip(att) } others.forEach { att -> FileChip(att) }
} }
@ -717,33 +447,17 @@ private fun FileChip(attachment: Attachment) {
"code" -> Icons.Rounded.Code "code" -> Icons.Rounded.Code
else -> Icons.AutoMirrored.Rounded.InsertDriveFile else -> Icons.AutoMirrored.Rounded.InsertDriveFile
} }
Row( Row(Modifier.clip(KaizenShapes.xs).background(bg).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Modifier
.clip(RoundedCornerShape(8.dp))
.background(bg)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp)) Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text( Text(attachment.name, color = cs.onSurfaceVariant, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
attachment.name,
color = cs.onSurfaceVariant,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} }
/** Crisp upward arrow drawn with strokes (sharper than a glyph). */
@Composable @Composable
private fun SendArrow(color: Color) { private fun SendArrow(color: Color) {
Canvas(Modifier.size(18.dp)) { Canvas(Modifier.size(18.dp)) {
val w = size.width val w = size.width; val h = size.height; val cx = w / 2f; val stroke = w * 0.135f
val h = size.height
val cx = w / 2f
val stroke = w * 0.135f
drawLine(color, Offset(cx, h * 0.82f), Offset(cx, h * 0.20f), stroke, StrokeCap.Round) drawLine(color, Offset(cx, h * 0.82f), Offset(cx, h * 0.20f), stroke, StrokeCap.Round)
drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.28f, h * 0.46f), stroke, StrokeCap.Round) drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.28f, h * 0.46f), stroke, StrokeCap.Round)
drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.72f, h * 0.46f), stroke, StrokeCap.Round) drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.72f, h * 0.46f), stroke, StrokeCap.Round)

View file

@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
@ -45,7 +43,6 @@ import androidx.compose.runtime.snapshotFlow
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.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@ -53,6 +50,10 @@ import androidx.compose.material3.Text
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
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.db.MessageEntity import dev.kaizen.app.db.MessageEntity
import dev.kaizen.app.db.toEntity import dev.kaizen.app.db.toEntity
import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.haptics.rememberHaptics
@ -416,19 +417,7 @@ fun ChatScreen(
} }
} }
// 2. Floating Top Menu Button (Apple Liquid Glass - completely independent of a header bar) // 2. Floating Top Menu Button
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)))
}
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
@ -436,16 +425,16 @@ fun ChatScreen(
.padding(start = 16.dp, top = 10.dp, end = 16.dp), .padding(start = 16.dp, top = 10.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level2,
cornerRadius = 22.dp,
modifier = Modifier modifier = Modifier
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds .size(44.dp)
.clip(CircleShape) .clickable { haptics.tick(); scope.launch { drawerState.open() } },
.background(menuGlassBg)
.border(1.2.dp, menuGlassBorder, CircleShape)
.clickable { haptics.tick(); scope.launch { drawerState.open() } }
.size(44.dp),
contentAlignment = Alignment.Center
) { ) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon( Icon(
imageVector = Icons.Rounded.Menu, imageVector = Icons.Rounded.Menu,
contentDescription = "Menü öffnen", contentDescription = "Menü öffnen",
@ -453,6 +442,7 @@ fun ChatScreen(
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
}
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
ModelPill( ModelPill(
label = modelDisplayName(session.config?.model ?: "", models), label = modelDisplayName(session.config?.model ?: "", models),
@ -468,9 +458,9 @@ fun ChatScreen(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.statusBarsPadding() .statusBarsPadding()
.padding(top = 62.dp, start = 24.dp, end = 24.dp) .padding(top = 62.dp, start = 24.dp, end = 24.dp)
.clip(RoundedCornerShape(12.dp)) .clip(KaizenShapes.sm)
.background(Color(0xFFDC2626).copy(alpha = 0.15f)) .background(Color(0xFFDC2626).copy(alpha = 0.15f))
.border(1.dp, Color(0xFFDC2626).copy(alpha = 0.3f), RoundedCornerShape(12.dp)) .border(1.dp, Color(0xFFDC2626).copy(alpha = 0.3f), KaizenShapes.sm)
.padding(horizontal = 14.dp, vertical = 8.dp), .padding(horizontal = 14.dp, vertical = 8.dp),
) { ) {
Text( Text(

View file

@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
@ -509,7 +509,7 @@ private fun CopyButton(code: String) {
Box( Box(
Modifier Modifier
.size(28.dp) .size(28.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.clickable { .clickable {
haptics.tick() haptics.tick()
copied = true copied = true

View file

@ -6,8 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.* import androidx.compose.material.icons.rounded.*
@ -35,9 +34,9 @@ fun ModelPill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier)
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
Row( Row(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(22.dp)) .clip(KaizenShapes.xl)
.background(cs.surface.copy(alpha = 0.7f)) .background(cs.surface.copy(alpha = 0.7f))
.border(1.dp, cs.onSurface.copy(alpha = 0.08f), RoundedCornerShape(22.dp)) .border(1.dp, cs.onSurface.copy(alpha = 0.08f), KaizenShapes.xl)
.clickable { onClick() } .clickable { onClick() }
.padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp), .padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -128,9 +127,9 @@ fun ModelSheet(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .clip(KaizenShapes.md)
.background(cs.surfaceVariant.copy(alpha = 0.4f)) .background(cs.surfaceVariant.copy(alpha = 0.4f))
.border(1.dp, cs.onSurface.copy(alpha = 0.06f), RoundedCornerShape(16.dp)) .border(1.dp, cs.onSurface.copy(alpha = 0.06f), KaizenShapes.md)
.padding(horizontal = 14.dp, vertical = 11.dp), .padding(horizontal = 14.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -229,7 +228,7 @@ private fun ModelRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.clip(RoundedCornerShape(16.dp)) .clip(KaizenShapes.md)
.background( .background(
if (selected) { if (selected) {
Amber.copy(alpha = 0.08f) Amber.copy(alpha = 0.08f)
@ -240,7 +239,7 @@ private fun ModelRow(
.border( .border(
width = 1.dp, width = 1.dp,
color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent, color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent,
shape = RoundedCornerShape(16.dp) shape = KaizenShapes.md
) )
.clickable { haptics.tick(); onClick() } .clickable { haptics.tick(); onClick() }
.padding(horizontal = 14.dp, vertical = 12.dp), .padding(horizontal = 14.dp, vertical = 12.dp),
@ -249,7 +248,7 @@ private fun ModelRow(
// Dynamic Colored Badge for Provider (Vertex AI or OpenRouter) // Dynamic Colored Badge for Provider (Vertex AI or OpenRouter)
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp)) .clip(KaizenShapes.xs)
.background(providerInfo.color.copy(alpha = 0.15f)) .background(providerInfo.color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) { ) {
@ -284,7 +283,7 @@ private fun ModelRow(
Box( Box(
Modifier Modifier
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.clickable { haptics.tick(); onToggleFavorite() }, .clickable { haptics.tick(); onToggleFavorite() },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View file

@ -3,7 +3,6 @@ package dev.kaizen.app.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -18,12 +17,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
@ -39,14 +35,16 @@ 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.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
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
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.theme.Amber import dev.kaizen.app.ui.theme.Amber
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@ -54,7 +52,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
/** Date-bucket header for a conversation, from its ISO `updatedAt` (Heute/Gestern/dd. MMM yyyy). */
private fun dateLabel(updatedAt: String?): String { private fun dateLabel(updatedAt: String?): String {
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER" val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER"
val date = instant.atZone(ZoneId.systemDefault()).toLocalDate() val date = instant.atZone(ZoneId.systemDefault()).toLocalDate()
@ -66,7 +63,6 @@ private fun dateLabel(updatedAt: String?): String {
} }
} }
/** Pinned first, then chronological date buckets — preserves the API's desc-updatedAt order. */
private fun groupConversations(conversations: List<ConversationSummary>): Map<String, List<ConversationSummary>> { private fun groupConversations(conversations: List<ConversationSummary>): Map<String, List<ConversationSummary>> {
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>() val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() } conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() }
@ -76,12 +72,6 @@ private fun groupConversations(conversations: List<ConversationSummary>): Map<St
return groups return groups
} }
/**
* Floating glassmorphic sidebar drawer content.
*
* Styled as a suspended glass panel with a thick "milky glass" background,
* dual-border specular light reflections, deep ambient shadows, and authentic Kaizen layouts.
*/
@Composable @Composable
fun KaizenSidebar( fun KaizenSidebar(
userName: String, userName: String,
@ -97,150 +87,85 @@ fun KaizenSidebar(
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
// 1. Thick, premium milky-glass background gradient GlassSurface(
val glassBg = remember(isDark) {
if (isDark) {
Brush.verticalGradient(
listOf(
Color(0xCC141B27), // Deep Obsidian-blue with 80% opacity
Color(0xE60A0E16) // Even darker at the bottom (90% opacity)
)
)
} else {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.94f), // Creamy white with 94% opacity
Color(0xFFF1F5F9).copy(alpha = 0.85f) // Slightly slate tint at bottom
)
)
}
}
// 2. Specular border light reflections (crisp glass edge highlight)
val glassBorder = remember(isDark) {
if (isDark) {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.16f), // Bright highlight on top-left
Color.White.copy(alpha = 0.02f) // Faint dark shade on bottom
)
)
} else {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.65f),
Color.Black.copy(alpha = 0.05f)
)
)
}
}
// Floating design: 12dp margins all around so it physically hovers above the background!
Box(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(300.dp) .width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp) // Standard signature (start, top, end, bottom) .padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
.shadow(elevation = 16.dp, shape = RoundedCornerShape(24.dp), clip = false) shape = KaizenShapes.lg,
.clip(RoundedCornerShape(24.dp)) tintAlpha = GlassTiers.sidebar(isDark),
.background(glassBg) shadowStack = KaizenShadows.level3,
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp)) cornerRadius = 24.dp,
) {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding() .statusBarsPadding()
.padding(16.dp) .padding(16.dp)
) { ) {
Column(Modifier.fillMaxSize()) { // Header
// --- HEADER Sektion: Brand title and New Chat button ---
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text("kaizen", color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
text = "kaizen",
color = cs.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
// Translucent "New Chat" pencil button
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000)) .background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() }, .clickable { onNewChat() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(Icons.Rounded.Edit, "Neuer Chat", tint = cs.onBackground, modifier = Modifier.size(18.dp))
imageVector = Icons.Rounded.Edit,
contentDescription = "Neuer Chat",
tint = cs.onBackground,
modifier = Modifier.size(18.dp)
)
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// --- SEARCH Sektion: Glassmorphic search capsule --- // Search
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border( .border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
1.dp,
if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f),
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp))
imageVector = Icons.Rounded.Search,
contentDescription = null,
tint = cs.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text("Suchen...", color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp)
text = "Suchen...",
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 14.sp
)
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
// --- BODY Sektion: Scrollable Chats list grouped by Date --- // Conversation list
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) { if (conversations.isEmpty()) {
Text( Text(
text = "Noch keine Chats", "Noch keine Chats",
color = cs.onSurfaceVariant.copy(alpha = 0.5f), color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp) modifier = Modifier.padding(start = 12.dp, top = 12.dp)
) )
} else { } else {
val grouped = remember(conversations) { groupConversations(conversations) } val grouped = remember(conversations) { groupConversations(conversations) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
grouped.forEach { (dateGroup, items) -> grouped.forEach { (dateGroup, items) ->
// Date header (e.g., HEUTE, 16. JUN 2026)
item(key = "header_$dateGroup") { item(key = "header_$dateGroup") {
Text( Text(
text = dateGroup, dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f), color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
) )
} }
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
HistoryItemRow( HistoryItemRow(
item = item, item = item,
@ -255,81 +180,50 @@ fun KaizenSidebar(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// --- FOOTER Sektion: User Profile Card --- // User card
// Clicking the card opens the brand settings screen cleanly (MVVM state)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(18.dp)) .clip(KaizenShapes.md)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border( .border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
1.dp,
if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f),
RoundedCornerShape(18.dp)
)
.clickable { onOpenSettings() } .clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// User Avatar bubble: Obsidian colored dynamic first-letter initials!
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)), .background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = userName.firstOrNull()?.toString()?.uppercase() ?: "A", userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
fontSize = 17.sp,
fontWeight = FontWeight.Bold
) )
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
text = userName,
color = cs.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold
)
} }
Icon(Icons.Rounded.Settings, "Einstellungen", tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = "Einstellungen",
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
// --- Logout: clears the encrypted token + returns to the login screen --- // Logout
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(14.dp)) .clip(KaizenShapes.sm)
.clickable { onLogout() } .clickable { onLogout() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(Icons.AutoMirrored.Rounded.Logout, "Abmelden", tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
imageVector = Icons.AutoMirrored.Rounded.Logout,
contentDescription = "Abmelden",
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text( Text("Abmelden", color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium)
text = "Abmelden",
color = cs.onSurfaceVariant.copy(alpha = 0.85f),
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
} }
} }
} }
@ -354,23 +248,17 @@ private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(CircleShape) .clip(KaizenShapes.pill)
.background(itemBg) .background(itemBg)
.border(1.2.dp, itemBorder, CircleShape) .border(1.2.dp, itemBorder, KaizenShapes.pill)
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 11.dp), .padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item.pinned) { if (item.pinned) {
Icon( Icon(Icons.Rounded.Star, null, tint = Amber, modifier = Modifier.size(14.dp))
imageVector = Icons.Rounded.Star,
contentDescription = null,
tint = Amber,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text( Text(
text = if (item.locked) "Gesperrter Chat" else item.title, text = if (item.locked) "Gesperrter Chat" else item.title,
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground, color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground,
@ -379,15 +267,9 @@ private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal, fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
if (item.locked) { if (item.locked) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Icon( Icon(Icons.Rounded.Lock, "Gesperrt", tint = Amber, modifier = Modifier.size(15.dp))
imageVector = Icons.Rounded.Lock,
contentDescription = "Gesperrt",
tint = Amber,
modifier = Modifier.size(15.dp)
)
} }
} }
} }

View file

@ -25,8 +25,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import dev.kaizen.app.ui.effect.GlassSurface
import androidx.compose.foundation.shape.RoundedCornerShape import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -81,53 +83,14 @@ fun SettingsCard(
) { ) {
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val glassBg = remember(isDark) { GlassSurface(
if (isDark) { modifier = modifier.fillMaxWidth(),
Brush.verticalGradient( shape = KaizenShapes.lg,
listOf( tintAlpha = GlassTiers.card(isDark),
Color(0xFF1E293B).copy(alpha = 0.55f), // Slate 800 (55% opacity) shadowStack = KaizenShadows.level2,
Color(0xFF0F172A).copy(alpha = 0.65f) // Slate 900 (65% opacity) cornerRadius = 24.dp,
)
)
} 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)
) { ) {
// Standard Column provides standard ColumnScope receiver to the child layout content slot Column(modifier = Modifier.fillMaxWidth().padding(18.dp)) {
Column(modifier = Modifier.fillMaxWidth()) {
content() content()
} }
} }
@ -152,7 +115,7 @@ fun SettingsOptionRow(
Box( Box(
modifier = Modifier modifier = Modifier
.size(38.dp) .size(38.dp)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background(cs.onBackground.copy(alpha = 0.06f)), .background(cs.onBackground.copy(alpha = 0.06f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -203,9 +166,9 @@ fun SettingsInputCapsule(
Box( Box(
modifier = modifier modifier = modifier
.widthIn(max = 180.dp) .widthIn(max = 180.dp)
.clip(RoundedCornerShape(12.dp)) .clip(KaizenShapes.sm)
.background(inputGlassBg) .background(inputGlassBg)
.border(1.dp, inputGlassBorder, RoundedCornerShape(12.dp)) .border(1.dp, inputGlassBorder, KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
@ -262,8 +225,8 @@ fun ThemeOptionPill(
Box( Box(
modifier = modifier modifier = modifier
.size(48.dp) .size(48.dp)
.shadow(elevation = 4.dp, shape = CircleShape, clip = false) .shadow(elevation = 4.dp, shape = KaizenShapes.circle, clip = false)
.clip(CircleShape) .clip(KaizenShapes.circle)
.clickable { onClick() } .clickable { onClick() }
.border( .border(
width = if (selected) 2.5.dp else 1.2.dp, width = if (selected) 2.5.dp else 1.2.dp,
@ -272,10 +235,10 @@ fun ThemeOptionPill(
} else { } else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.35f), Color.White.copy(alpha = 0.05f))) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.35f), Color.White.copy(alpha = 0.05f)))
}, },
shape = CircleShape shape = KaizenShapes.circle
) )
.padding(if (selected) 3.dp else 0.dp) // creates a beautiful inner gap for the selected ring .padding(if (selected) 3.dp else 0.dp) // creates a beautiful inner gap for the selected ring
.clip(CircleShape) .clip(KaizenShapes.circle)
) { ) {
// Draw the perception gradient on Canvas // Draw the perception gradient on Canvas
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
@ -317,8 +280,8 @@ fun AppearanceTogglePill(
Box( Box(
modifier = modifier modifier = modifier
.shadow(elevation = if (selected) 4.dp else 0.dp, shape = CircleShape, clip = false) .shadow(elevation = if (selected) 4.dp else 0.dp, shape = KaizenShapes.circle, clip = false)
.clip(CircleShape) .clip(KaizenShapes.circle)
.background( .background(
if (selected) { if (selected) {
Amber.copy(alpha = 0.16f) Amber.copy(alpha = 0.16f)
@ -333,7 +296,7 @@ fun AppearanceTogglePill(
} else { } else {
glassBorder glassBorder
}, },
shape = CircleShape shape = KaizenShapes.circle
) )
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 9.dp), .padding(horizontal = 16.dp, vertical = 9.dp),

View file

@ -17,10 +17,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.AutoAwesome
import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
@ -35,9 +38,6 @@ 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.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.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
@ -64,21 +64,6 @@ fun SettingsScreen(
val userName by viewModel.userName.collectAsState() val userName by viewModel.userName.collectAsState()
val userEmail by viewModel.userEmail.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 { MeshBackground {
Column( Column(
modifier = modifier modifier = modifier
@ -95,22 +80,16 @@ fun SettingsScreen(
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( GlassSurface(
modifier = Modifier shape = KaizenShapes.circle,
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) tintAlpha = GlassTiers.pill(isDark),
.clip(CircleShape) shadowStack = KaizenShadows.level2,
.background(backBtnGlassBg) cornerRadius = 22.dp,
.border(1.2.dp, backBtnGlassBorder, CircleShape) modifier = Modifier.size(44.dp).clickable { onBack() },
.clickable { onBack() }
.size(44.dp),
contentAlignment = Alignment.Center
) { ) {
Icon( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
imageVector = Icons.Rounded.ArrowBack, Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Zurück", tint = cs.onBackground, modifier = Modifier.size(20.dp))
contentDescription = "Zurück", }
tint = cs.onBackground,
modifier = Modifier.size(20.dp)
)
} }
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))

View file

@ -1,6 +1,5 @@
package dev.kaizen.app.ui.effect package dev.kaizen.app.ui.effect
import android.os.Build
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@ -10,10 +9,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -21,16 +18,19 @@ import androidx.compose.ui.unit.dp
import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.ui.shape.KaizenShapes
/** /**
* A refractive glass surface the core visual primitive of the Kaizen design system. * A frosted-glass surface the core visual primitive of the Kaizen design system.
* *
* On API 31+ uses Compose's [Modifier.blur] (backed by RenderEffect.createBlurEffect) * Android has no `backdrop-filter: blur()` real refractive blur would require
* to blur the underlying composition through the surface, creating a real frosted-glass * capturing and blurring the layer behind, which is not feasible in Compose's
* effect. On API 2630, the blur is skipped and the surface uses a higher tint alpha * rendering model. Instead, the glass effect is achieved through:
* (+0.10) to fake depth.
* *
* The [tint] color gives the surface its identity (Obsidian vs white). Without it * 1. Semi-transparent gradient tint (gives the surface body and color identity)
* the blur would show a wash of whatever's behind. [tintAlpha] controls the * 2. Specular border highlight (top-bright, bottom-dim gradient border)
* "milkiness" lower = more transparent/refractive, higher = more opaque/solid. * 3. Inner top highlight (1dp lit edge, simulates overhead light)
* 4. Multi-layer shadow stack (floating depth)
*
* The blob layer behind (MeshBackground) is what "shows through" the
* transparency, creating the frosted-glass illusion.
* *
* See DESIGN.md §4 for the full anatomy and opacity tiers. * See DESIGN.md §4 for the full anatomy and opacity tiers.
*/ */
@ -38,35 +38,51 @@ import dev.kaizen.app.ui.shape.KaizenShapes
fun GlassSurface( fun GlassSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shape: Shape = KaizenShapes.md, shape: Shape = KaizenShapes.md,
tint: Color = MaterialTheme.colorScheme.surface,
tintAlpha: Float = 0.72f, tintAlpha: Float = 0.72f,
blurRadius: Dp = 24.dp,
shadowStack: ShadowStack = KaizenShadows.level2, shadowStack: ShadowStack = KaizenShadows.level2,
cornerRadius: Dp = 16.dp,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val supportsBlur = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val cs = MaterialTheme.colorScheme
val effectiveTintAlpha = if (supportsBlur) tintAlpha else (tintAlpha + 0.10f).coerceAtMost(1f) val glassBg = remember(isDark, tintAlpha) {
val tintColor = remember(tint, effectiveTintAlpha) { tint.copy(alpha = effectiveTintAlpha) } if (isDark) {
Brush.verticalGradient(
listOf(
Color(0xFF1E293B).copy(alpha = tintAlpha),
Color(0xFF0F172A).copy(alpha = (tintAlpha + 0.10f).coerceAtMost(1f)),
)
)
} else {
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = tintAlpha),
Color(0xFFF1F5F9).copy(alpha = (tintAlpha - 0.05f).coerceAtLeast(0f)),
)
)
}
}
val outlineColor = MaterialTheme.colorScheme.outline val glassBorder = remember(isDark) {
val outlineAlpha = if (isDark) 0.12f else 0.06f 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))
)
}
}
Box( Box(
modifier = modifier modifier = modifier
.kaizenShadow(stack = shadowStack, cornerRadius = 16.dp) .kaizenShadow(stack = shadowStack, cornerRadius = cornerRadius)
.clip(shape) .clip(shape)
.then( .background(glassBg)
if (supportsBlur) {
Modifier.blur(blurRadius, edgeTreatment = BlurredEdgeTreatment.Rectangle)
} else {
Modifier
}
)
.drawBehind { drawRect(tintColor) }
.innerTopHighlight() .innerTopHighlight()
.border(1.dp, outlineColor.copy(alpha = outlineAlpha), shape), .border(1.2.dp, glassBorder, shape),
content = content, content = content,
) )
} }
@ -76,24 +92,11 @@ fun GlassSurface(
* Use these constants with [GlassSurface] instead of ad-hoc alpha values. * Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/ */
object GlassTiers { object GlassTiers {
/** Sidebar — most translucent, strongest refraction */
fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f
/** Chat input bar */
fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
/** Small pills (mode pill, model pill) */
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
/** Suggestion chips — tap targets, need body */
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f
/** Settings cards — reading surface, near-opaque */
fun card(isDark: Boolean) = if (isDark) 0.94f else 0.95f fun card(isDark: Boolean) = if (isDark) 0.94f else 0.95f
/** User bubble — amber-tinted glass */
fun userBubble(isDark: Boolean) = if (isDark) 0.86f else 0.88f fun userBubble(isDark: Boolean) = if (isDark) 0.86f else 0.88f
/** Dropdowns / bottom sheets — deepest blur */
fun sheet(isDark: Boolean) = if (isDark) 0.82f else 0.85f fun sheet(isDark: Boolean) = if (isDark) 0.82f else 0.85f
} }