feat(ui): implement liquid glass styling, floating panels, and perfect soft-keyboard inset handling
This commit is contained in:
parent
4cd117e703
commit
43ca73c04a
2 changed files with 212 additions and 35 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
if (messages.isEmpty()) {
|
||||
EmptyHero(userName = userName, onPick = { haptics.tick(); send(it) })
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(messages, key = { it.id }) { message -> MessageRow(message) }
|
||||
}
|
||||
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) },
|
||||
modifier = Modifier.imePadding() // Viewport shrinks so the scrollable Column adapts without warping
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue