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.horizontalScroll
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -64,31 +65,77 @@ import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KaizenHeader() {
|
fun KaizenHeader(modifier: Modifier = Modifier) {
|
||||||
val cs = MaterialTheme.colorScheme
|
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(
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
KaizenOrb(24.dp)
|
KaizenOrb(24.dp)
|
||||||
Spacer(Modifier.width(9.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyHero(userName: String, onPick: (String) -> Unit) {
|
fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val now = remember { LocalDateTime.now() }
|
val now = remember { LocalDateTime.now() }
|
||||||
val dateLine = remember {
|
val dateLine = remember {
|
||||||
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
|
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
|
||||||
}
|
}
|
||||||
// One grouped, scrollable block so the keyboard never squeezes or clips it.
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp),
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(64.dp))
|
Spacer(Modifier.height(88.dp)) // Clear the floating glass header
|
||||||
KaizenOrb(104.dp)
|
KaizenOrb(104.dp)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text("${greeting(now.hour)}, $userName", color = cs.onSurfaceVariant, fontSize = 17.sp)
|
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)
|
Text(dateLine, color = cs.onSurfaceVariant.copy(alpha = 0.7f), fontSize = 13.sp)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
SuggestionPills(onPick)
|
SuggestionPills(onPick)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(142.dp)) // Clear the floating bottom dock
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionPills(onPick: (String) -> Unit) {
|
private fun SuggestionPills(onPick: (String) -> Unit) {
|
||||||
val cs = MaterialTheme.colorScheme
|
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(
|
FlowRow(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||||
|
|
@ -114,8 +170,8 @@ private fun SuggestionPills(onPick: (String) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(cs.surface.copy(alpha = 0.82f))
|
.background(glassBg)
|
||||||
.border(1.dp, cs.outline, CircleShape)
|
.border(1.dp, glassBorder, CircleShape)
|
||||||
.clickable { onPick(suggestion.prompt) }
|
.clickable { onPick(suggestion.prompt) }
|
||||||
.padding(horizontal = 16.dp, vertical = 11.dp),
|
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -129,19 +185,49 @@ private fun SuggestionPills(onPick: (String) -> Unit) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ModePillsRow(selected: String, onSelect: (String) -> Unit) {
|
fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||||
val cs = MaterialTheme.colorScheme
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 14.dp),
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
chatModes.forEach { mode ->
|
chatModes.forEach { mode ->
|
||||||
val active = mode.label == selected
|
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(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (active) cs.primary.copy(alpha = 0.16f) else cs.surface.copy(alpha = 0.7f))
|
.background(backgroundFill)
|
||||||
.border(1.dp, if (active) cs.primary.copy(alpha = 0.40f) else cs.outline, CircleShape)
|
.border(1.2.dp, borderBrush, CircleShape)
|
||||||
.clickable { onSelect(mode.label) }
|
.clickable { onSelect(mode.label) }
|
||||||
.padding(horizontal = 13.dp, vertical = 8.dp),
|
.padding(horizontal = 13.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -149,7 +235,7 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit) {
|
||||||
Icon(
|
Icon(
|
||||||
mode.icon,
|
mode.icon,
|
||||||
null,
|
null,
|
||||||
tint = if (active) cs.primary else cs.onSurfaceVariant,
|
tint = if (active) Amber else cs.onSurfaceVariant,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
|
|
@ -167,15 +253,36 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit) {
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageRow(message: Message) {
|
fun MessageRow(message: Message) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
if (message.role == Role.User) {
|
if (message.role == Role.User) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
val shape = RoundedCornerShape(20.dp, 20.dp, 6.dp, 20.dp)
|
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.widthIn(max = 300.dp)
|
.widthIn(max = 300.dp)
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(Amber.copy(alpha = 0.16f))
|
.background(bgBrush)
|
||||||
.border(1.dp, Amber.copy(alpha = 0.30f), shape)
|
.border(1.2.dp, borderBrush, shape)
|
||||||
.padding(horizontal = 16.dp, vertical = 11.dp),
|
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||||
) {
|
) {
|
||||||
Text(message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp)
|
Text(message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp)
|
||||||
|
|
@ -232,18 +339,62 @@ fun ChatInput(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val cs = MaterialTheme.colorScheme
|
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(
|
Row(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
.clip(RoundedCornerShape(28.dp))
|
.clip(RoundedCornerShape(28.dp))
|
||||||
.background(cs.surfaceVariant.copy(alpha = 0.94f))
|
.background(glassBg)
|
||||||
.border(1.dp, cs.outline, RoundedCornerShape(28.dp))
|
.border(1.2.dp, glassBorder, RoundedCornerShape(28.dp))
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
GhostIconButton(Icons.Rounded.Add, "Anhang")
|
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()) {
|
if (value.isEmpty()) {
|
||||||
Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp)
|
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.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.kaizen.app.haptics.rememberHaptics
|
import dev.kaizen.app.haptics.rememberHaptics
|
||||||
|
|
@ -78,23 +79,47 @@ fun ChatScreen(userName: String = "Bruno") {
|
||||||
}
|
}
|
||||||
|
|
||||||
MeshBackground {
|
MeshBackground {
|
||||||
Column(Modifier.fillMaxSize().statusBarsPadding()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
KaizenHeader()
|
// 1. Scrollable Chat Content
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
// Stretches to the full screen, allowing bubbles to scroll cleanly behind the floating glass panels.
|
||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
EmptyHero(userName = userName, onPick = { haptics.tick(); send(it) })
|
EmptyHero(
|
||||||
} else {
|
userName = userName,
|
||||||
LazyColumn(
|
onPick = { haptics.tick(); send(it) },
|
||||||
state = listState,
|
modifier = Modifier.imePadding() // Viewport shrinks so the scrollable Column adapts without warping
|
||||||
modifier = Modifier.fillMaxSize(),
|
)
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
} else {
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
LazyColumn(
|
||||||
) {
|
state = listState,
|
||||||
items(messages, key = { it.id }) { message -> MessageRow(message) }
|
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() })
|
ModePillsRow(selected = selectedMode, onSelect = { selectedMode = it; haptics.tick() })
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
ChatInput(
|
ChatInput(
|
||||||
|
|
@ -103,6 +128,7 @@ fun ChatScreen(userName: String = "Bruno") {
|
||||||
onSend = { send(input) },
|
onSend = { send(input) },
|
||||||
enabled = !isStreaming,
|
enabled = !isStreaming,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(12.dp)) // Extra breathing margin at the bottom of the floating card
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue