feat(ui): implement liquid glass styling, floating panels, and perfect soft-keyboard inset handling

This commit is contained in:
Bruno Deanoz 2026-06-18 16:29:15 +02:00
parent 4cd117e703
commit 43ca73c04a
2 changed files with 212 additions and 35 deletions

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -64,31 +65,77 @@ import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun KaizenHeader() {
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.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
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,
) {
KaizenOrb(24.dp)
Spacer(Modifier.width(9.dp))
Spacer(Modifier.width(10.dp))
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
fun EmptyHero(userName: String, onPick: (String) -> Unit) {
fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
val now = remember { LocalDateTime.now() }
val dateLine = remember {
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
}
// One grouped, scrollable block so the keyboard never squeezes or clips it.
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp),
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(64.dp))
Spacer(Modifier.height(88.dp)) // Clear the floating glass header
KaizenOrb(104.dp)
Spacer(Modifier.height(24.dp))
Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
@ -98,13 +145,22 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit) {
Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp)
Spacer(Modifier.height(32.dp))
SuggestionPills(onPick)
Spacer(Modifier.height(24.dp))
Spacer(Modifier.height(142.dp)) // Clear the floating bottom dock
}
}
@Composable
private fun SuggestionPills(onPick: (String) -> Unit) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val glassBg = if (isDark) cs.surface.copy(alpha = 0.40f) else cs.surface.copy(alpha = 0.85f)
val glassBorder = if (isDark) {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.02f)))
} else {
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.04f)))
}
FlowRow(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
@ -114,8 +170,8 @@ private fun SuggestionPills(onPick: (String) -> Unit) {
Row(
Modifier
.clip(CircleShape)
.background(cs.surface.copy(alpha = 0.82f))
.border(1.dp, cs.outline, CircleShape)
.background(glassBg)
.border(1.dp, glassBorder, CircleShape)
.clickable { onPick(suggestion.prompt) }
.padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
@ -129,19 +185,49 @@ private fun SuggestionPills(onPick: (String) -> Unit) {
}
@Composable
fun ModePillsRow(selected: String, onSelect: (String) -> Unit) {
fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
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))
)
}
}
val glassBorderActive = remember(isDark) {
Brush.verticalGradient(
listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f))
)
}
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 14.dp),
modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
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 borderBrush = if (active) glassBorderActive else glassBorderInactive
Row(
Modifier
.clip(CircleShape)
.background(if (active) cs.primary.copy(alpha = 0.16f) else cs.surface.copy(alpha = 0.7f))
.border(1.dp, if (active) cs.primary.copy(alpha = 0.40f) else cs.outline, CircleShape)
.background(backgroundFill)
.border(1.2.dp, borderBrush, CircleShape)
.clickable { onSelect(mode.label) }
.padding(horizontal = 13.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@ -149,7 +235,7 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit) {
Icon(
mode.icon,
null,
tint = if (active) cs.primary else cs.onSurfaceVariant,
tint = if (active) Amber else cs.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
@ -167,15 +253,36 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit) {
@Composable
fun MessageRow(message: Message) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
if (message.role == Role.User) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp)
// Specular highlighting and gradient matching the oklch brand colors
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)
.clip(shape)
.background(Amber.copy(alpha = 0.16f))
.border(1.dp, Amber.copy(alpha = 0.30f), shape)
.background(bgBrush)
.border(1.2.dp, borderBrush, shape)
.padding(horizontal = 16.dp, vertical = 11.dp),
) {
Text(message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp)
@ -232,18 +339,62 @@ fun ChatInput(
modifier: Modifier = Modifier,
) {
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)
)
)
}
}
Row(
modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(28.dp))
.background(cs.surfaceVariant.copy(alpha = 0.94f))
.border(1.dp, cs.outline, RoundedCornerShape(28.dp))
.background(glassBg)
.border(1.2.dp, glassBorder, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GhostIconButton(Icons.Rounded.Add, "Anhang")
Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.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)
}

View file

@ -22,6 +22,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.kaizen.app.haptics.rememberHaptics
@ -78,23 +79,47 @@ fun ChatScreen(userName: String = "Bruno") {
}
MeshBackground {
Column(Modifier.fillMaxSize().statusBarsPadding()) {
KaizenHeader()
Box(Modifier.weight(1f).fillMaxWidth()) {
Box(Modifier.fillMaxSize()) {
// 1. Scrollable Chat Content
// Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating glass panels.
if (messages.isEmpty()) {
EmptyHero(userName = userName, onPick = { haptics.tick(); send(it) })
EmptyHero(
userName = userName,
onPick = { haptics.tick(); send(it) },
modifier = Modifier.imePadding() // Viewport shrinks so the scrollable Column adapts without warping
)
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
modifier = Modifier
.fillMaxSize()
.imePadding(), // Adjusts viewport height so scrollable content stops exactly above the keyboard
contentPadding = PaddingValues(
top = 92.dp, // Leaves luxurious room for the floating KaizenHeader + status bars
bottom = 152.dp, // Leaves luxurious room for the floating ModePills + ChatInput + bottom margins
start = 16.dp,
end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(messages, key = { it.id }) { message -> MessageRow(message) }
}
}
}
Column(Modifier.navigationBarsPadding().imePadding()) {
// 2. Floating Header (Translucent Frosted Glass)
KaizenHeader(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
)
// 3. Floating Bottom Control Dock
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.imePadding()
) {
ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() })
Spacer(Modifier.height(10.dp))
ChatInput(
@ -103,6 +128,7 @@ fun ChatScreen(userName: String = "Bruno") {
onSend = { send(input) },
enabled = !isStreaming,
)
Spacer(Modifier.height(12.dp)) // Extra breathing margin at the bottom of the floating card
}
}
}