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:
parent
4d71475995
commit
94c0f85fb7
9 changed files with 256 additions and 726 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,52 +94,16 @@ 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)
|
||||
|
|
@ -151,6 +111,7 @@ fun KaizenHeader(modifier: Modifier = Modifier) {
|
|||
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue