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.widthIn
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.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@ -178,10 +178,10 @@ private fun GlassField(
Modifier
.fillMaxWidth()
.widthIn(max = 420.dp)
.shadow(elevation = 6.dp, shape = RoundedCornerShape(20.dp), clip = false)
.clip(RoundedCornerShape(20.dp))
.shadow(elevation = 6.dp, shape = KaizenShapes.lg, clip = false)
.clip(KaizenShapes.lg)
.background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(20.dp))
.border(1.2.dp, glassBorder, KaizenShapes.lg)
.heightIn(min = 54.dp)
.padding(horizontal = 18.dp, vertical = 16.dp),
contentAlignment = Alignment.CenterStart,
@ -216,8 +216,8 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit
.fillMaxWidth()
.widthIn(max = 420.dp)
.heightIn(min = 54.dp)
.shadow(elevation = 8.dp, shape = RoundedCornerShape(20.dp), clip = false)
.clip(RoundedCornerShape(20.dp))
.shadow(elevation = 8.dp, shape = KaizenShapes.lg, clip = false)
.clip(KaizenShapes.lg)
.then(fill)
.clickable(enabled = enabled, onClick = onClick)
.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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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.verticalScroll
import androidx.compose.material.icons.Icons
@ -53,7 +48,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
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.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.AmberDeep
import dev.kaizen.app.ui.theme.OnAmber
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.OptIn
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material.icons.rounded.AudioFile
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Code
@ -98,58 +94,23 @@ import java.util.concurrent.TimeUnit
@Composable
fun KaizenHeader(modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
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
GlassSurface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(24.dp))
.background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
shape = KaizenShapes.lg,
shadowStack = KaizenShadows.level1,
cornerRadius = 24.dp,
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
KaizenOrb(24.dp)
Spacer(Modifier.width(10.dp))
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
}
}
@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))
}
// Dynamic keyboard visibility check (Google Gemini style)
// 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
// DESIGN.md §11.1: no suggestion chips on mobile — redundant with mode pills
Column(
modifier = modifier
.fillMaxSize()
@ -173,7 +130,7 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
.padding(horizontal = 24.dp),
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)
Spacer(Modifier.height(24.dp))
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)
Spacer(Modifier.height(8.dp))
Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp)
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)
}
}
Spacer(Modifier.height(142.dp))
}
}
@ -240,21 +148,11 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
val isDark = isSystemInDarkTheme()
val glassBorderInactive = 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))
)
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)))
}
}
val glassBorderActive = remember(isDark) {
Brush.verticalGradient(
listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f))
)
Brush.verticalGradient(listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f)))
}
Row(
@ -266,36 +164,22 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
) {
chatModes.forEach { mode ->
val active = mode.label == selected
val backgroundFill = if (active) {
Amber.copy(alpha = 0.16f)
} else {
if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
}
val backgroundFill = if (active) Amber.copy(alpha = 0.16f)
else if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
val borderBrush = if (active) glassBorderActive else glassBorderInactive
Row(
Modifier
.shadow(elevation = 2.dp, shape = CircleShape, clip = false) // Floating tabs
.clip(CircleShape)
.clip(KaizenShapes.pill)
.background(backgroundFill)
.border(1.2.dp, borderBrush, CircleShape)
.border(1.2.dp, borderBrush, KaizenShapes.pill)
.clickable { onSelect(mode.label) }
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
mode.icon,
null,
tint = if (active) Amber else cs.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Icon(mode.icon, null, tint = if (active) Amber else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
mode.label,
color = if (active) cs.onBackground else cs.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
Text(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) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val hasAttachments = message.attachments.isNotEmpty()
if (message.role == Role.User) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp)
val borderBrush = remember {
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)))
}
val shape = KaizenShapes.lg
val borderBrush = remember { 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(
Modifier
.widthIn(max = 300.dp)
.shadow(elevation = 3.dp, shape = shape, clip = false)
.clip(shape)
.background(bgBrush)
.border(1.2.dp, borderBrush, shape),
) {
Column {
if (hasAttachments) {
AttachmentGrid(message.attachments, Modifier.padding(6.dp))
}
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
if (message.content.isNotBlank()) {
Text(
message.content,
color = cs.onBackground,
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,
),
message.content, color = cs.onBackground, 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)
if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp))
}
if (message.thinking) {
TypingDots()
} else {
MarkdownText(text = message.content, streaming = message.streaming)
}
if (message.thinking) TypingDots()
else MarkdownText(text = message.content, streaming = message.streaming)
}
}
}
@ -375,26 +240,18 @@ private fun TypingDots() {
Row(verticalAlignment = Alignment.CenterVertically) {
repeat(3) { i ->
val alpha by transition.animateFloat(
initialValue = 0.25f,
targetValue = 1f,
initialValue = 0.25f, targetValue = 1f,
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,
),
label = "dot$i",
)
Box(
Modifier
.padding(end = 5.dp)
.size(7.dp)
.clip(CircleShape)
.background(cs.onSurfaceVariant.copy(alpha = alpha)),
)
Box(Modifier.padding(end = 5.dp).size(7.dp).clip(KaizenShapes.circle).background(cs.onSurfaceVariant.copy(alpha = alpha)))
}
}
}
/** A file pending upload or already uploaded, shown as a preview chip above the input. */
data class PendingFile(
val id: String = java.util.UUID.randomUUID().toString(),
val name: String,
@ -419,106 +276,49 @@ fun ChatInput(
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val glassBg = remember(isDark) {
if (isDark) {
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
GlassSurface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.shadow(elevation = 8.dp, shape = RoundedCornerShape(28.dp), clip = false)
.clip(RoundedCornerShape(28.dp))
.background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)),
.padding(horizontal = 12.dp),
shape = KaizenShapes.xl,
tintAlpha = GlassTiers.input(isDark),
shadowStack = KaizenShadows.level2,
cornerRadius = 28.dp,
) {
Column {
if (pendingFiles.isNotEmpty()) {
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
pendingFiles.forEach { pf ->
PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) })
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
}
}
}
Row(
Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onAddClick),
contentAlignment = Alignment.Center,
) {
Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Add, "Anhang", tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
}
Box(
Modifier
.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)
}
Box(Modifier.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(
value = value,
onValueChange = onValueChange,
value = value, onValueChange = onValueChange,
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
cursorBrush = SolidColor(Amber),
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
cursorBrush = SolidColor(Amber), maxLines = 6, modifier = Modifier.fillMaxWidth(),
)
}
GhostIconButton(Icons.Rounded.Call, "Anruf")
Spacer(Modifier.width(4.dp))
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null })
&& pendingFiles.none { it.uploading }
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
SendButton(enabled = canSend, onClick = onSend)
}
}
}
}
@Composable
private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
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))
}
}
@ -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 isImage = pf.mimeType.startsWith("image/")
Box(
Modifier
.size(64.dp)
.clip(RoundedCornerShape(10.dp))
.background(bg),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(64.dp).clip(KaizenShapes.xs).background(bg), contentAlignment = Alignment.Center) {
if (isImage && pf.uploaded != null) {
NetworkImage(
url = pf.uploaded.url,
contentDescription = pf.name,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(10.dp)),
)
NetworkImage(url = pf.uploaded.url, contentDescription = pf.name, modifier = Modifier.fillMaxSize().clip(KaizenShapes.xs))
} else {
val icon = when {
pf.mimeType.startsWith("image/") -> Icons.Rounded.Image
@ -553,46 +343,21 @@ private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) {
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(
pf.name,
color = cs.onSurfaceVariant,
fontSize = 9.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp),
)
Text(pf.name, color = cs.onSurfaceVariant, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 4.dp))
}
}
if (pf.uploading) {
Box(
Modifier.fillMaxSize()
.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,
)
Box(Modifier.fillMaxSize().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) {
Box(
Modifier.fillMaxSize()
.background(Color(0xFFDC2626).copy(alpha = 0.3f)),
contentAlignment = Alignment.Center,
) {
Box(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))
}
}
Box(
Modifier
.align(Alignment.TopEnd)
.padding(2.dp)
.size(18.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
.clickable(onClick = onRemove),
Modifier.align(Alignment.TopEnd).padding(2.dp).size(18.dp).clip(KaizenShapes.circle).background(Color.Black.copy(alpha = 0.5f)).clickable(onClick = onRemove),
contentAlignment = Alignment.Center,
) {
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 pressed by interaction.collectIsPressedAsState()
val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send")
val fill = if (enabled) {
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
} else {
Modifier.background(cs.surface)
}
val fill = if (enabled) Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
else Modifier.background(cs.surface)
Box(
Modifier
.size(44.dp)
.scale(pressScale)
.clip(CircleShape)
.then(fill)
.border(1.dp, cs.outline, CircleShape)
Modifier.size(44.dp).scale(pressScale).clip(KaizenShapes.circle).then(fill)
.border(1.dp, cs.outline, KaizenShapes.circle)
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center,
) {
@ -631,10 +390,7 @@ private object NetworkImageCache {
private val cache = object : LinkedHashMap<String, ImageBitmap>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = size > 50
}
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val client = OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build()
suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) {
synchronized(cache) { cache[url] }?.let { return@withContext it }
@ -647,8 +403,7 @@ private object NetworkImageCache {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe)
val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536)
val opts = BitmapFactory.Options().apply { inSampleSize = scale }
val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
?: return@withContext null
val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null
bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } }
}
} catch (_: Exception) { null }
@ -656,31 +411,14 @@ private object NetworkImageCache {
}
@Composable
private fun NetworkImage(
url: String,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
private fun NetworkImage(url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop) {
var bitmap by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) }
val bmp = bitmap
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
Image(bitmap = bmp, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale)
} else {
Box(
modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
)
Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)))
}
}
@ -689,17 +427,9 @@ fun AttachmentGrid(attachments: List<Attachment>, modifier: Modifier = Modifier)
if (attachments.isEmpty()) return
val images = attachments.filter { it.kind == "image" }
val others = attachments.filter { it.kind != "image" }
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
images.forEach { att ->
NetworkImage(
url = att.url,
contentDescription = att.name,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
.clip(RoundedCornerShape(10.dp)),
)
NetworkImage(url = att.url, contentDescription = att.name, modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp).clip(KaizenShapes.xs))
}
others.forEach { att -> FileChip(att) }
}
@ -717,33 +447,17 @@ private fun FileChip(attachment: Attachment) {
"code" -> Icons.Rounded.Code
else -> Icons.AutoMirrored.Rounded.InsertDriveFile
}
Row(
Modifier
.clip(RoundedCornerShape(8.dp))
.background(bg)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(Modifier.clip(KaizenShapes.xs).background(bg).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
attachment.name,
color = cs.onSurfaceVariant,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(attachment.name, color = cs.onSurfaceVariant, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/** Crisp upward arrow drawn with strokes (sharper than a glyph). */
@Composable
private fun SendArrow(color: Color) {
Canvas(Modifier.size(18.dp)) {
val w = size.width
val h = size.height
val cx = w / 2f
val stroke = w * 0.135f
val w = size.width; 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.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)

View file

@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.rounded.Menu
import androidx.compose.material3.DrawerValue
@ -45,7 +43,6 @@ import androidx.compose.runtime.snapshotFlow
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.platform.LocalConfiguration
@ -53,6 +50,10 @@ import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.toEntity
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)
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)))
}
// 2. Floating Top Menu Button
Row(
modifier = Modifier
.align(Alignment.TopStart)
@ -436,16 +425,16 @@ fun ChatScreen(
.padding(start = 16.dp, top = 10.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level2,
cornerRadius = 22.dp,
modifier = Modifier
.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
.size(44.dp)
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = "Menü öffnen",
@ -453,6 +442,7 @@ fun ChatScreen(
modifier = Modifier.size(20.dp)
)
}
}
Spacer(Modifier.width(8.dp))
ModelPill(
label = modelDisplayName(session.config?.model ?: "", models),
@ -468,9 +458,9 @@ fun ChatScreen(
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 62.dp, start = 24.dp, end = 24.dp)
.clip(RoundedCornerShape(12.dp))
.clip(KaizenShapes.sm)
.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),
) {
Text(

View file

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

View file

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

View file

@ -3,7 +3,6 @@ package dev.kaizen.app.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
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.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout
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.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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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 java.time.Instant
import java.time.LocalDate
@ -54,7 +52,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
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 {
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER"
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>> {
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
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
}
/**
* 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
fun KaizenSidebar(
userName: String,
@ -97,150 +87,85 @@ fun KaizenSidebar(
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
// 1. Thick, premium milky-glass background gradient
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(
GlassSurface(
modifier = modifier
.fillMaxHeight()
.width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp) // Standard signature (start, top, end, bottom)
.shadow(elevation = 16.dp, shape = RoundedCornerShape(24.dp), clip = false)
.clip(RoundedCornerShape(24.dp))
.background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp))
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg,
tintAlpha = GlassTiers.sidebar(isDark),
shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp,
) {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(16.dp)
) {
Column(Modifier.fillMaxSize()) {
// --- HEADER Sektion: Brand title and New Chat button ---
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "kaizen",
color = cs.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
// Translucent "New Chat" pencil button
Text("kaizen", color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = "Neuer Chat",
tint = cs.onBackground,
modifier = Modifier.size(18.dp)
)
Icon(Icons.Rounded.Edit, "Neuer Chat", tint = cs.onBackground, modifier = Modifier.size(18.dp))
}
}
Spacer(Modifier.height(16.dp))
// --- SEARCH Sektion: Glassmorphic search capsule ---
// Search
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border(
1.dp,
if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f),
RoundedCornerShape(12.dp)
)
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
tint = cs.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(18.dp)
)
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.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))
// --- BODY Sektion: Scrollable Chats list grouped by Date ---
// Conversation list
Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) {
Text(
text = "Noch keine Chats",
"Noch keine Chats",
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 14.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
)
} else {
val grouped = remember(conversations) { groupConversations(conversations) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
grouped.forEach { (dateGroup, items) ->
// Date header (e.g., HEUTE, 16. JUN 2026)
item(key = "header_$dateGroup") {
Text(
text = dateGroup,
dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
)
}
items(items, key = { it.id }) { item ->
HistoryItemRow(
item = item,
@ -255,81 +180,50 @@ fun KaizenSidebar(
Spacer(Modifier.height(16.dp))
// --- FOOTER Sektion: User Profile Card ---
// Clicking the card opens the brand settings screen cleanly (MVVM state)
// User card
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.clip(KaizenShapes.md)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border(
1.dp,
if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f),
RoundedCornerShape(18.dp)
)
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
.clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// User Avatar bubble: Obsidian colored dynamic first-letter initials!
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center
) {
Text(
text = userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold
userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.width(10.dp))
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(
imageVector = Icons.Rounded.Settings,
contentDescription = "Einstellungen",
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
Icon(Icons.Rounded.Settings, "Einstellungen", tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
}
Spacer(Modifier.height(10.dp))
// --- Logout: clears the encrypted token + returns to the login screen ---
// Logout
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clip(KaizenShapes.sm)
.clickable { onLogout() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Logout,
contentDescription = "Abmelden",
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
Icon(Icons.AutoMirrored.Rounded.Logout, "Abmelden", tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.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(
modifier = Modifier
.fillMaxWidth()
.clip(CircleShape)
.clip(KaizenShapes.pill)
.background(itemBg)
.border(1.2.dp, itemBorder, CircleShape)
.border(1.2.dp, itemBorder, KaizenShapes.pill)
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.pinned) {
Icon(
imageVector = Icons.Rounded.Star,
contentDescription = null,
tint = Amber,
modifier = Modifier.size(14.dp)
)
Icon(Icons.Rounded.Star, null, tint = Amber, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(8.dp))
}
Text(
text = if (item.locked) "Gesperrter Chat" else item.title,
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,
modifier = Modifier.weight(1f)
)
if (item.locked) {
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Rounded.Lock,
contentDescription = "Gesperrt",
tint = Amber,
modifier = Modifier.size(15.dp)
)
Icon(Icons.Rounded.Lock, "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.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@ -81,53 +83,14 @@ fun SettingsCard(
) {
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)
GlassSurface(
modifier = modifier.fillMaxWidth(),
shape = KaizenShapes.lg,
tintAlpha = GlassTiers.card(isDark),
shadowStack = KaizenShadows.level2,
cornerRadius = 24.dp,
) {
// Standard Column provides standard ColumnScope receiver to the child layout content slot
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(18.dp)) {
content()
}
}
@ -152,7 +115,7 @@ fun SettingsOptionRow(
Box(
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.clip(KaizenShapes.circle)
.background(cs.onBackground.copy(alpha = 0.06f)),
contentAlignment = Alignment.Center
) {
@ -203,9 +166,9 @@ fun SettingsInputCapsule(
Box(
modifier = modifier
.widthIn(max = 180.dp)
.clip(RoundedCornerShape(12.dp))
.clip(KaizenShapes.sm)
.background(inputGlassBg)
.border(1.dp, inputGlassBorder, RoundedCornerShape(12.dp))
.border(1.dp, inputGlassBorder, KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
@ -262,8 +225,8 @@ fun ThemeOptionPill(
Box(
modifier = modifier
.size(48.dp)
.shadow(elevation = 4.dp, shape = CircleShape, clip = false)
.clip(CircleShape)
.shadow(elevation = 4.dp, shape = KaizenShapes.circle, clip = false)
.clip(KaizenShapes.circle)
.clickable { onClick() }
.border(
width = if (selected) 2.5.dp else 1.2.dp,
@ -272,10 +235,10 @@ fun ThemeOptionPill(
} else {
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
.clip(CircleShape)
.clip(KaizenShapes.circle)
) {
// Draw the perception gradient on Canvas
Canvas(Modifier.fillMaxSize()) {
@ -317,8 +280,8 @@ fun AppearanceTogglePill(
Box(
modifier = modifier
.shadow(elevation = if (selected) 4.dp else 0.dp, shape = CircleShape, clip = false)
.clip(CircleShape)
.shadow(elevation = if (selected) 4.dp else 0.dp, shape = KaizenShapes.circle, clip = false)
.clip(KaizenShapes.circle)
.background(
if (selected) {
Amber.copy(alpha = 0.16f)
@ -333,7 +296,7 @@ fun AppearanceTogglePill(
} else {
glassBorder
},
shape = CircleShape
shape = KaizenShapes.circle
)
.clickable { onClick() }
.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.width
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.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.Palette
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.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
@ -64,21 +64,6 @@ fun SettingsScreen(
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
@ -95,22 +80,16 @@ fun SettingsScreen(
.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
GlassSurface(
shape = KaizenShapes.circle,
tintAlpha = GlassTiers.pill(isDark),
shadowStack = KaizenShadows.level2,
cornerRadius = 22.dp,
modifier = Modifier.size(44.dp).clickable { onBack() },
) {
Icon(
imageVector = Icons.Rounded.ArrowBack,
contentDescription = "Zurück",
tint = cs.onBackground,
modifier = Modifier.size(20.dp)
)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Zurück", tint = cs.onBackground, modifier = Modifier.size(20.dp))
}
}
Spacer(Modifier.width(16.dp))

View file

@ -1,6 +1,5 @@
package dev.kaizen.app.ui.effect
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
@ -10,10 +9,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
@ -21,16 +18,19 @@ import androidx.compose.ui.unit.dp
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)
* to blur the underlying composition through the surface, creating a real frosted-glass
* effect. On API 2630, the blur is skipped and the surface uses a higher tint alpha
* (+0.10) to fake depth.
* Android has no `backdrop-filter: blur()` real refractive blur would require
* capturing and blurring the layer behind, which is not feasible in Compose's
* rendering model. Instead, the glass effect is achieved through:
*
* The [tint] color gives the surface its identity (Obsidian vs white). Without it
* the blur would show a wash of whatever's behind. [tintAlpha] controls the
* "milkiness" lower = more transparent/refractive, higher = more opaque/solid.
* 1. Semi-transparent gradient tint (gives the surface body and color identity)
* 2. Specular border highlight (top-bright, bottom-dim gradient border)
* 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.
*/
@ -38,35 +38,51 @@ import dev.kaizen.app.ui.shape.KaizenShapes
fun GlassSurface(
modifier: Modifier = Modifier,
shape: Shape = KaizenShapes.md,
tint: Color = MaterialTheme.colorScheme.surface,
tintAlpha: Float = 0.72f,
blurRadius: Dp = 24.dp,
shadowStack: ShadowStack = KaizenShadows.level2,
cornerRadius: Dp = 16.dp,
content: @Composable BoxScope.() -> Unit,
) {
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 tintColor = remember(tint, effectiveTintAlpha) { tint.copy(alpha = effectiveTintAlpha) }
val glassBg = remember(isDark, tintAlpha) {
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 outlineAlpha = if (isDark) 0.12f else 0.06f
val glassBorder = 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))
)
}
}
Box(
modifier = modifier
.kaizenShadow(stack = shadowStack, cornerRadius = 16.dp)
.kaizenShadow(stack = shadowStack, cornerRadius = cornerRadius)
.clip(shape)
.then(
if (supportsBlur) {
Modifier.blur(blurRadius, edgeTreatment = BlurredEdgeTreatment.Rectangle)
} else {
Modifier
}
)
.drawBehind { drawRect(tintColor) }
.background(glassBg)
.innerTopHighlight()
.border(1.dp, outlineColor.copy(alpha = outlineAlpha), shape),
.border(1.2.dp, glassBorder, shape),
content = content,
)
}
@ -76,24 +92,11 @@ fun GlassSurface(
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/
object GlassTiers {
/** Sidebar — most translucent, strongest refraction */
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
/** Small pills (mode pill, model pill) */
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
/** Settings cards — reading surface, near-opaque */
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
/** Dropdowns / bottom sheets — deepest blur */
fun sheet(isDark: Boolean) = if (isDark) 0.82f else 0.85f
}