From 94c0f85fb76541626f015289d16cd9ce467a5e4e Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Sun, 21 Jun 2026 14:50:11 +0200 Subject: [PATCH] feat: migrate all UI components to design system v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../java/dev/kaizen/app/auth/LoginScreen.kt | 12 +- .../dev/kaizen/app/chat/ChatComponents.kt | 472 ++++-------------- .../java/dev/kaizen/app/chat/ChatScreen.kt | 54 +- .../main/java/dev/kaizen/app/chat/Markdown.kt | 4 +- .../java/dev/kaizen/app/chat/ModelSheet.kt | 19 +- .../main/java/dev/kaizen/app/chat/Sidebar.kt | 206 ++------ .../kaizen/app/settings/SettingsComponents.kt | 79 +-- .../dev/kaizen/app/settings/SettingsScreen.kt | 49 +- .../dev/kaizen/app/ui/effect/GlassSurface.kt | 87 ++-- 9 files changed, 256 insertions(+), 726 deletions(-) diff --git a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt index b22c889..15b08af 100644 --- a/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt +++ b/app/src/main/java/dev/kaizen/app/auth/LoginScreen.kt @@ -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), diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 5a349e6..01c70a7 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -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,57 +94,22 @@ 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), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 16.dp, vertical = 10.dp), + shape = KaizenShapes.lg, + shadowStack = KaizenShadows.level1, + cornerRadius = 24.dp, ) { - KaizenOrb(24.dp) - Spacer(Modifier.width(10.dp)) - Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + 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) + } } } @@ -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,98 +276,41 @@ 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, ) { - if (pendingFiles.isNotEmpty()) { - Row( - 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) }) + Column { + if (pendingFiles.isNotEmpty()) { + Row( + 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) }) } } } - } - 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, - ) { - 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) + 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)) } - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), - cursorBrush = SolidColor(Amber), - maxLines = 6, - modifier = Modifier.fillMaxWidth(), - ) + 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, + textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp), + 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 } + SendButton(enabled = canSend, onClick = onSend) } - GhostIconButton(Icons.Rounded.Call, "Anruf") - Spacer(Modifier.width(4.dp)) - val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) - && pendingFiles.none { it.uploading } - SendButton(enabled = canSend, onClick = onSend) } } } @@ -518,7 +318,7 @@ fun ChatInput( @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(32, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = 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(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, 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) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index d8679c4..980741c 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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,22 +425,23 @@ 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() } }, ) { - Icon( - imageVector = Icons.Rounded.Menu, - contentDescription = "Menü öffnen", - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(20.dp) - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Menu, + contentDescription = "Menü öffnen", + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(20.dp) + ) + } } Spacer(Modifier.width(8.dp)) ModelPill( @@ -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( diff --git a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt index 0988e20..16d4ed5 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt @@ -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 diff --git a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt index 4e7127e..ba69255 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ModelSheet.kt @@ -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, ) { diff --git a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt index 16d0763..fa83f15 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt @@ -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): Map> { val groups = LinkedHashMap>() conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() } @@ -76,12 +72,6 @@ private fun groupConversations(conversations: List): Map - // 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)) } } } diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt index 59493b9..1bc3b11 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsComponents.kt @@ -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), diff --git a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt index 55094f4..b067324 100644 --- a/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/kaizen/app/settings/SettingsScreen.kt @@ -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)) diff --git a/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt b/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt index 45331b6..2a252a1 100644 --- a/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt +++ b/app/src/main/java/dev/kaizen/app/ui/effect/GlassSurface.kt @@ -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 26–30, 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 }