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.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
|
@ -178,10 +178,10 @@ private fun GlassField(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
.shadow(elevation = 6.dp, shape = RoundedCornerShape(20.dp), clip = false)
|
.shadow(elevation = 6.dp, shape = KaizenShapes.lg, clip = false)
|
||||||
.clip(RoundedCornerShape(20.dp))
|
.clip(KaizenShapes.lg)
|
||||||
.background(glassBg)
|
.background(glassBg)
|
||||||
.border(1.2.dp, glassBorder, RoundedCornerShape(20.dp))
|
.border(1.2.dp, glassBorder, KaizenShapes.lg)
|
||||||
.heightIn(min = 54.dp)
|
.heightIn(min = 54.dp)
|
||||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
|
@ -216,8 +216,8 @@ private fun SignInButton(enabled: Boolean, loading: Boolean, onClick: () -> Unit
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
.heightIn(min = 54.dp)
|
.heightIn(min = 54.dp)
|
||||||
.shadow(elevation = 8.dp, shape = RoundedCornerShape(20.dp), clip = false)
|
.shadow(elevation = 8.dp, shape = KaizenShapes.lg, clip = false)
|
||||||
.clip(RoundedCornerShape(20.dp))
|
.clip(KaizenShapes.lg)
|
||||||
.then(fill)
|
.then(fill)
|
||||||
.clickable(enabled = enabled, onClick = onClick)
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
.padding(vertical = 16.dp),
|
.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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.ime
|
|
||||||
import androidx.compose.foundation.layout.isImeVisible
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.text.BasicTextField
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -53,7 +48,6 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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.StrokeCap
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.kaizen.app.net.Attachment
|
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.Amber
|
||||||
import dev.kaizen.app.ui.theme.AmberDeep
|
import dev.kaizen.app.ui.theme.AmberDeep
|
||||||
import dev.kaizen.app.ui.theme.OnAmber
|
import dev.kaizen.app.ui.theme.OnAmber
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.OptIn
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.material.icons.rounded.AudioFile
|
import androidx.compose.material.icons.rounded.AudioFile
|
||||||
import androidx.compose.material.icons.rounded.Close
|
import androidx.compose.material.icons.rounded.Close
|
||||||
import androidx.compose.material.icons.rounded.Code
|
import androidx.compose.material.icons.rounded.Code
|
||||||
|
|
@ -98,52 +94,16 @@ import java.util.concurrent.TimeUnit
|
||||||
@Composable
|
@Composable
|
||||||
fun KaizenHeader(modifier: Modifier = Modifier) {
|
fun KaizenHeader(modifier: Modifier = Modifier) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
GlassSurface(
|
||||||
|
modifier = modifier
|
||||||
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()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
.clip(RoundedCornerShape(24.dp))
|
shape = KaizenShapes.lg,
|
||||||
.background(glassBg)
|
shadowStack = KaizenShadows.level1,
|
||||||
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp))
|
cornerRadius = 24.dp,
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
KaizenOrb(24.dp)
|
KaizenOrb(24.dp)
|
||||||
|
|
@ -151,6 +111,7 @@ fun KaizenHeader(modifier: Modifier = Modifier) {
|
||||||
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
Text("Kaizen", color = cs.onBackground, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@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))
|
now.format(DateTimeFormatter.ofPattern("EEE, d. MMMM · HH:mm", Locale.GERMAN))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic keyboard visibility check (Google Gemini style)
|
// DESIGN.md §11.1: no suggestion chips on mobile — redundant with mode pills
|
||||||
// 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
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -173,7 +130,7 @@ fun EmptyHero(userName: String, onPick: (String) -> Unit, modifier: Modifier = M
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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)
|
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)
|
||||||
|
|
@ -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)
|
Text("Was liegt an?", color = cs.onBackground, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
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(142.dp))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,21 +148,11 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
val glassBorderInactive = remember(isDark) {
|
val glassBorderInactive = remember(isDark) {
|
||||||
if (isDark) {
|
if (isDark) Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.02f)))
|
||||||
Brush.verticalGradient(
|
else Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.45f), Color.Black.copy(alpha = 0.04f)))
|
||||||
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) {
|
val glassBorderActive = remember(isDark) {
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f)))
|
||||||
listOf(Amber.copy(alpha = 0.50f), Amber.copy(alpha = 0.15f))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -266,36 +164,22 @@ fun ModePillsRow(selected: String, onSelect: (String) -> Unit, modifier: Modifie
|
||||||
) {
|
) {
|
||||||
chatModes.forEach { mode ->
|
chatModes.forEach { mode ->
|
||||||
val active = mode.label == selected
|
val active = mode.label == selected
|
||||||
val backgroundFill = if (active) {
|
val backgroundFill = if (active) Amber.copy(alpha = 0.16f)
|
||||||
Amber.copy(alpha = 0.16f)
|
else if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
|
||||||
} else {
|
|
||||||
if (isDark) Color(0x12FFFFFF) else Color(0x82FFFFFF)
|
|
||||||
}
|
|
||||||
val borderBrush = if (active) glassBorderActive else glassBorderInactive
|
val borderBrush = if (active) glassBorderActive else glassBorderInactive
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.shadow(elevation = 2.dp, shape = CircleShape, clip = false) // Floating tabs
|
.clip(KaizenShapes.pill)
|
||||||
.clip(CircleShape)
|
|
||||||
.background(backgroundFill)
|
.background(backgroundFill)
|
||||||
.border(1.2.dp, borderBrush, CircleShape)
|
.border(1.2.dp, borderBrush, KaizenShapes.pill)
|
||||||
.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,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(mode.icon, null, tint = if (active) Amber else cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
||||||
mode.icon,
|
|
||||||
null,
|
|
||||||
tint = if (active) Amber else cs.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(
|
Text(mode.label, color = if (active) cs.onBackground else cs.onSurfaceVariant, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||||
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) {
|
fun MessageRow(message: Message) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
val hasAttachments = message.attachments.isNotEmpty()
|
val hasAttachments = message.attachments.isNotEmpty()
|
||||||
|
|
||||||
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 = KaizenShapes.lg
|
||||||
|
val borderBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.45f), AmberDeep.copy(alpha = 0.15f))) }
|
||||||
val borderBrush = remember {
|
val bgBrush = remember { Brush.verticalGradient(listOf(Amber.copy(alpha = 0.18f), AmberDeep.copy(alpha = 0.08f))) }
|
||||||
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)
|
||||||
.shadow(elevation = 3.dp, shape = shape, clip = false)
|
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(bgBrush)
|
.background(bgBrush)
|
||||||
.border(1.2.dp, borderBrush, shape),
|
.border(1.2.dp, borderBrush, shape),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (hasAttachments) {
|
if (hasAttachments) AttachmentGrid(message.attachments, Modifier.padding(6.dp))
|
||||||
AttachmentGrid(message.attachments, Modifier.padding(6.dp))
|
|
||||||
}
|
|
||||||
if (message.content.isNotBlank()) {
|
if (message.content.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
message.content,
|
message.content, color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp,
|
||||||
color = cs.onBackground,
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (hasAttachments) 4.dp else 11.dp, bottom = 11.dp),
|
||||||
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)
|
AttachmentGrid(message.attachments)
|
||||||
if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp))
|
if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
if (message.thinking) {
|
if (message.thinking) TypingDots()
|
||||||
TypingDots()
|
else MarkdownText(text = message.content, streaming = message.streaming)
|
||||||
} else {
|
|
||||||
MarkdownText(text = message.content, streaming = message.streaming)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -375,26 +240,18 @@ private fun TypingDots() {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
repeat(3) { i ->
|
repeat(3) { i ->
|
||||||
val alpha by transition.animateFloat(
|
val alpha by transition.animateFloat(
|
||||||
initialValue = 0.25f,
|
initialValue = 0.25f, targetValue = 1f,
|
||||||
targetValue = 1f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
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,
|
RepeatMode.Reverse,
|
||||||
),
|
),
|
||||||
label = "dot$i",
|
label = "dot$i",
|
||||||
)
|
)
|
||||||
Box(
|
Box(Modifier.padding(end = 5.dp).size(7.dp).clip(KaizenShapes.circle).background(cs.onSurfaceVariant.copy(alpha = alpha)))
|
||||||
Modifier
|
|
||||||
.padding(end = 5.dp)
|
|
||||||
.size(7.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(cs.onSurfaceVariant.copy(alpha = alpha)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A file pending upload or already uploaded, shown as a preview chip above the input. */
|
|
||||||
data class PendingFile(
|
data class PendingFile(
|
||||||
val id: String = java.util.UUID.randomUUID().toString(),
|
val id: String = java.util.UUID.randomUUID().toString(),
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|
@ -419,106 +276,49 @@ fun ChatInput(
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
val glassBg = remember(isDark) {
|
GlassSurface(
|
||||||
if (isDark) {
|
modifier = modifier
|
||||||
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
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp),
|
||||||
.shadow(elevation = 8.dp, shape = RoundedCornerShape(28.dp), clip = false)
|
shape = KaizenShapes.xl,
|
||||||
.clip(RoundedCornerShape(28.dp))
|
tintAlpha = GlassTiers.input(isDark),
|
||||||
.background(glassBg)
|
shadowStack = KaizenShadows.level2,
|
||||||
.border(1.2.dp, glassBorder, RoundedCornerShape(28.dp)),
|
cornerRadius = 28.dp,
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
if (pendingFiles.isNotEmpty()) {
|
if (pendingFiles.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 8.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
pendingFiles.forEach { pf ->
|
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
|
||||||
PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Row(
|
Box(Modifier.size(40.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick), contentAlignment = Alignment.Center) {
|
||||||
Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onAddClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Rounded.Add, "Anhang", tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
Icon(Icons.Rounded.Add, "Anhang", tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
Box(
|
Box(Modifier.weight(1f).heightIn(min = 36.dp).padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart) {
|
||||||
Modifier
|
if (value.isEmpty()) Text("Nachricht eingeben …", color = cs.onSurfaceVariant, fontSize = 16.sp)
|
||||||
.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(
|
BasicTextField(
|
||||||
value = value,
|
value = value, onValueChange = onValueChange,
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
|
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 22.sp),
|
||||||
cursorBrush = SolidColor(Amber),
|
cursorBrush = SolidColor(Amber), maxLines = 6, modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 6,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(Icons.Rounded.Call, "Anruf")
|
GhostIconButton(Icons.Rounded.Call, "Anruf")
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null })
|
val canSend = enabled && (value.isNotBlank() || pendingFiles.any { it.uploaded != null }) && pendingFiles.none { it.uploading }
|
||||||
&& pendingFiles.none { it.uploading }
|
|
||||||
SendButton(enabled = canSend, onClick = onSend)
|
SendButton(enabled = canSend, onClick = onSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
|
private fun GhostIconButton(icon: ImageVector, contentDescription: String) {
|
||||||
val cs = MaterialTheme.colorScheme
|
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))
|
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 bg = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
|
||||||
val isImage = pf.mimeType.startsWith("image/")
|
val isImage = pf.mimeType.startsWith("image/")
|
||||||
|
|
||||||
Box(
|
Box(Modifier.size(64.dp).clip(KaizenShapes.xs).background(bg), contentAlignment = Alignment.Center) {
|
||||||
Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.background(bg),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isImage && pf.uploaded != null) {
|
if (isImage && pf.uploaded != null) {
|
||||||
NetworkImage(
|
NetworkImage(url = pf.uploaded.url, contentDescription = pf.name, modifier = Modifier.fillMaxSize().clip(KaizenShapes.xs))
|
||||||
url = pf.uploaded.url,
|
|
||||||
contentDescription = pf.name,
|
|
||||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(10.dp)),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
val icon = when {
|
val icon = when {
|
||||||
pf.mimeType.startsWith("image/") -> Icons.Rounded.Image
|
pf.mimeType.startsWith("image/") -> Icons.Rounded.Image
|
||||||
|
|
@ -553,46 +343,21 @@ private fun PendingFileChip(pf: PendingFile, onRemove: () -> Unit) {
|
||||||
}
|
}
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||||
Text(
|
Text(pf.name, color = cs.onSurfaceVariant, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
pf.name,
|
|
||||||
color = cs.onSurfaceVariant,
|
|
||||||
fontSize = 9.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(horizontal = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pf.uploading) {
|
if (pf.uploading) {
|
||||||
Box(
|
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) {
|
||||||
Modifier.fillMaxSize()
|
androidx.compose.material3.CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
|
||||||
.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) {
|
if (pf.error != null) {
|
||||||
Box(
|
Box(Modifier.fillMaxSize().background(Color(0xFFDC2626).copy(alpha = 0.3f)), contentAlignment = Alignment.Center) {
|
||||||
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))
|
Icon(Icons.Rounded.Close, "Fehler", tint = Color.White, modifier = Modifier.size(18.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier.align(Alignment.TopEnd).padding(2.dp).size(18.dp).clip(KaizenShapes.circle).background(Color.Black.copy(alpha = 0.5f)).clickable(onClick = onRemove),
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(2.dp)
|
|
||||||
.size(18.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Black.copy(alpha = 0.5f))
|
|
||||||
.clickable(onClick = onRemove),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Close, "Entfernen", tint = Color.White, modifier = Modifier.size(12.dp))
|
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 interaction = remember { MutableInteractionSource() }
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send")
|
val pressScale by animateFloatAsState(if (pressed) 0.86f else 1f, label = "send")
|
||||||
val fill = if (enabled) {
|
val fill = if (enabled) Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
|
||||||
Modifier.background(Brush.linearGradient(listOf(Amber, AmberDeep)))
|
else Modifier.background(cs.surface)
|
||||||
} else {
|
|
||||||
Modifier.background(cs.surface)
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier.size(44.dp).scale(pressScale).clip(KaizenShapes.circle).then(fill)
|
||||||
.size(44.dp)
|
.border(1.dp, cs.outline, KaizenShapes.circle)
|
||||||
.scale(pressScale)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.then(fill)
|
|
||||||
.border(1.dp, cs.outline, CircleShape)
|
|
||||||
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
|
.clickable(interactionSource = interaction, indication = null, enabled = enabled, onClick = onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|
@ -631,10 +390,7 @@ private object NetworkImageCache {
|
||||||
private val cache = object : LinkedHashMap<String, ImageBitmap>(32, 0.75f, true) {
|
private val cache = object : LinkedHashMap<String, ImageBitmap>(32, 0.75f, true) {
|
||||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = size > 50
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?) = size > 50
|
||||||
}
|
}
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
suspend fun load(url: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
synchronized(cache) { cache[url] }?.let { return@withContext it }
|
synchronized(cache) { cache[url] }?.let { return@withContext it }
|
||||||
|
|
@ -647,8 +403,7 @@ private object NetworkImageCache {
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe)
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, probe)
|
||||||
val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536)
|
val scale = maxOf(1, maxOf(probe.outWidth, probe.outHeight) / 1536)
|
||||||
val opts = BitmapFactory.Options().apply { inSampleSize = scale }
|
val opts = BitmapFactory.Options().apply { inSampleSize = scale }
|
||||||
val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) ?: return@withContext null
|
||||||
?: return@withContext null
|
|
||||||
bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } }
|
bmp.asImageBitmap().also { synchronized(cache) { cache[url] = it } }
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
|
|
@ -656,31 +411,14 @@ private object NetworkImageCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NetworkImage(
|
private fun NetworkImage(url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop) {
|
||||||
url: String,
|
|
||||||
contentDescription: String?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
contentScale: ContentScale = ContentScale.Crop,
|
|
||||||
) {
|
|
||||||
var bitmap by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
var bitmap by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) }
|
LaunchedEffect(url) { bitmap = NetworkImageCache.load(url) }
|
||||||
|
|
||||||
val bmp = bitmap
|
val bmp = bitmap
|
||||||
if (bmp != null) {
|
if (bmp != null) {
|
||||||
Image(
|
Image(bitmap = bmp, contentDescription = contentDescription, modifier = modifier, contentScale = contentScale)
|
||||||
bitmap = bmp,
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = modifier,
|
|
||||||
contentScale = contentScale,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(modifier.fillMaxWidth().height(160.dp).clip(KaizenShapes.xs).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)))
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(160.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -689,17 +427,9 @@ fun AttachmentGrid(attachments: List<Attachment>, modifier: Modifier = Modifier)
|
||||||
if (attachments.isEmpty()) return
|
if (attachments.isEmpty()) return
|
||||||
val images = attachments.filter { it.kind == "image" }
|
val images = attachments.filter { it.kind == "image" }
|
||||||
val others = attachments.filter { it.kind != "image" }
|
val others = attachments.filter { it.kind != "image" }
|
||||||
|
|
||||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
images.forEach { att ->
|
images.forEach { att ->
|
||||||
NetworkImage(
|
NetworkImage(url = att.url, contentDescription = att.name, modifier = Modifier.fillMaxWidth().heightIn(max = 280.dp).clip(KaizenShapes.xs))
|
||||||
url = att.url,
|
|
||||||
contentDescription = att.name,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(max = 280.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
others.forEach { att -> FileChip(att) }
|
others.forEach { att -> FileChip(att) }
|
||||||
}
|
}
|
||||||
|
|
@ -717,33 +447,17 @@ private fun FileChip(attachment: Attachment) {
|
||||||
"code" -> Icons.Rounded.Code
|
"code" -> Icons.Rounded.Code
|
||||||
else -> Icons.AutoMirrored.Rounded.InsertDriveFile
|
else -> Icons.AutoMirrored.Rounded.InsertDriveFile
|
||||||
}
|
}
|
||||||
Row(
|
Row(Modifier.clip(KaizenShapes.xs).background(bg).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(bg)
|
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
Icon(icon, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(
|
Text(attachment.name, color = cs.onSurfaceVariant, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
attachment.name,
|
|
||||||
color = cs.onSurfaceVariant,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Crisp upward arrow drawn with strokes (sharper than a glyph). */
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SendArrow(color: Color) {
|
private fun SendArrow(color: Color) {
|
||||||
Canvas(Modifier.size(18.dp)) {
|
Canvas(Modifier.size(18.dp)) {
|
||||||
val w = size.width
|
val w = size.width; val h = size.height; val cx = w / 2f; val stroke = w * 0.135f
|
||||||
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.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.28f, h * 0.46f), stroke, StrokeCap.Round)
|
||||||
drawLine(color, Offset(cx, h * 0.18f), Offset(w * 0.72f, 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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.Icons
|
||||||
import androidx.compose.material.icons.rounded.Menu
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
|
|
@ -45,7 +43,6 @@ import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.MessageEntity
|
||||||
import dev.kaizen.app.db.toEntity
|
import dev.kaizen.app.db.toEntity
|
||||||
import dev.kaizen.app.haptics.rememberHaptics
|
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)
|
// 2. Floating Top Menu Button
|
||||||
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)))
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
|
|
@ -436,16 +425,16 @@ fun ChatScreen(
|
||||||
.padding(start = 16.dp, top = 10.dp, end = 16.dp),
|
.padding(start = 16.dp, top = 10.dp, end = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
GlassSurface(
|
||||||
|
shape = KaizenShapes.circle,
|
||||||
|
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
|
||||||
|
shadowStack = KaizenShadows.level2,
|
||||||
|
cornerRadius = 22.dp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.shadow(elevation = 6.dp, shape = CircleShape, clip = false) // Floats above backgrounds
|
.size(44.dp)
|
||||||
.clip(CircleShape)
|
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
|
||||||
.background(menuGlassBg)
|
|
||||||
.border(1.2.dp, menuGlassBorder, CircleShape)
|
|
||||||
.clickable { haptics.tick(); scope.launch { drawerState.open() } }
|
|
||||||
.size(44.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Menu,
|
imageVector = Icons.Rounded.Menu,
|
||||||
contentDescription = "Menü öffnen",
|
contentDescription = "Menü öffnen",
|
||||||
|
|
@ -453,6 +442,7 @@ fun ChatScreen(
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
ModelPill(
|
ModelPill(
|
||||||
label = modelDisplayName(session.config?.model ?: "", models),
|
label = modelDisplayName(session.config?.model ?: "", models),
|
||||||
|
|
@ -468,9 +458,9 @@ fun ChatScreen(
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(top = 62.dp, start = 24.dp, end = 24.dp)
|
.padding(top = 62.dp, start = 24.dp, end = 24.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(KaizenShapes.sm)
|
||||||
.background(Color(0xFFDC2626).copy(alpha = 0.15f))
|
.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),
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Check
|
import androidx.compose.material.icons.rounded.Check
|
||||||
import androidx.compose.material.icons.rounded.ContentCopy
|
import androidx.compose.material.icons.rounded.ContentCopy
|
||||||
|
|
@ -509,7 +509,7 @@ private fun CopyButton(code: String) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.circle)
|
||||||
.clickable {
|
.clickable {
|
||||||
haptics.tick()
|
haptics.tick()
|
||||||
copied = true
|
copied = true
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import dev.kaizen.app.ui.shape.KaizenShapes
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.*
|
import androidx.compose.material.icons.rounded.*
|
||||||
|
|
@ -35,9 +34,9 @@ fun ModelPill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier)
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clip(RoundedCornerShape(22.dp))
|
.clip(KaizenShapes.xl)
|
||||||
.background(cs.surface.copy(alpha = 0.7f))
|
.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() }
|
.clickable { onClick() }
|
||||||
.padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp),
|
.padding(start = 14.dp, end = 8.dp, top = 9.dp, bottom = 9.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -128,9 +127,9 @@ fun ModelSheet(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(KaizenShapes.md)
|
||||||
.background(cs.surfaceVariant.copy(alpha = 0.4f))
|
.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),
|
.padding(horizontal = 14.dp, vertical = 11.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -229,7 +228,7 @@ private fun ModelRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(KaizenShapes.md)
|
||||||
.background(
|
.background(
|
||||||
if (selected) {
|
if (selected) {
|
||||||
Amber.copy(alpha = 0.08f)
|
Amber.copy(alpha = 0.08f)
|
||||||
|
|
@ -240,7 +239,7 @@ private fun ModelRow(
|
||||||
.border(
|
.border(
|
||||||
width = 1.dp,
|
width = 1.dp,
|
||||||
color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent,
|
color = if (selected) Amber.copy(alpha = 0.25f) else Color.Transparent,
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = KaizenShapes.md
|
||||||
)
|
)
|
||||||
.clickable { haptics.tick(); onClick() }
|
.clickable { haptics.tick(); onClick() }
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
|
@ -249,7 +248,7 @@ private fun ModelRow(
|
||||||
// Dynamic Colored Badge for Provider (Vertex AI or OpenRouter)
|
// Dynamic Colored Badge for Provider (Vertex AI or OpenRouter)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(KaizenShapes.xs)
|
||||||
.background(providerInfo.color.copy(alpha = 0.15f))
|
.background(providerInfo.color.copy(alpha = 0.15f))
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -284,7 +283,7 @@ private fun ModelRow(
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.circle)
|
||||||
.clickable { haptics.tick(); onToggleFavorite() },
|
.clickable { haptics.tick(); onToggleFavorite() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package dev.kaizen.app.chat
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
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
|
||||||
|
|
@ -18,12 +17,9 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.Logout
|
import androidx.compose.material.icons.automirrored.rounded.Logout
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.kaizen.app.net.ConversationSummary
|
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 dev.kaizen.app.ui.theme.Amber
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
@ -54,7 +52,6 @@ import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
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 {
|
private fun dateLabel(updatedAt: String?): String {
|
||||||
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER"
|
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return "FRÜHER"
|
||||||
val date = instant.atZone(ZoneId.systemDefault()).toLocalDate()
|
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>> {
|
private fun groupConversations(conversations: List<ConversationSummary>): Map<String, List<ConversationSummary>> {
|
||||||
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
|
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
|
||||||
conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups["★ ANGEHEFTET"] = it.toMutableList() }
|
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
|
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
|
@Composable
|
||||||
fun KaizenSidebar(
|
fun KaizenSidebar(
|
||||||
userName: String,
|
userName: String,
|
||||||
|
|
@ -97,150 +87,85 @@ fun KaizenSidebar(
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
// 1. Thick, premium milky-glass background gradient
|
GlassSurface(
|
||||||
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(
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(300.dp)
|
.width(300.dp)
|
||||||
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp) // Standard signature (start, top, end, bottom)
|
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
|
||||||
.shadow(elevation = 16.dp, shape = RoundedCornerShape(24.dp), clip = false)
|
shape = KaizenShapes.lg,
|
||||||
.clip(RoundedCornerShape(24.dp))
|
tintAlpha = GlassTiers.sidebar(isDark),
|
||||||
.background(glassBg)
|
shadowStack = KaizenShadows.level3,
|
||||||
.border(1.2.dp, glassBorder, RoundedCornerShape(24.dp))
|
cornerRadius = 24.dp,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
// Header
|
||||||
|
|
||||||
// --- HEADER Sektion: Brand title and New Chat button ---
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text("kaizen", color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||||
text = "kaizen",
|
|
||||||
color = cs.onBackground,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
// Translucent "New Chat" pencil button
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.circle)
|
||||||
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
|
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
|
||||||
.clickable { onNewChat() },
|
.clickable { onNewChat() },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Rounded.Edit, "Neuer Chat", tint = cs.onBackground, modifier = Modifier.size(18.dp))
|
||||||
imageVector = Icons.Rounded.Edit,
|
|
||||||
contentDescription = "Neuer Chat",
|
|
||||||
tint = cs.onBackground,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// --- SEARCH Sektion: Glassmorphic search capsule ---
|
// Search
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(KaizenShapes.sm)
|
||||||
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
|
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
|
||||||
.border(
|
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
|
||||||
1.dp,
|
|
||||||
if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f),
|
|
||||||
RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp))
|
||||||
imageVector = Icons.Rounded.Search,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = cs.onSurfaceVariant.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.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))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
// --- BODY Sektion: Scrollable Chats list grouped by Date ---
|
// Conversation list
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (conversations.isEmpty()) {
|
if (conversations.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Noch keine Chats",
|
"Noch keine Chats",
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
|
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val grouped = remember(conversations) { groupConversations(conversations) }
|
val grouped = remember(conversations) { groupConversations(conversations) }
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
grouped.forEach { (dateGroup, items) ->
|
grouped.forEach { (dateGroup, items) ->
|
||||||
// Date header (e.g., HEUTE, 16. JUN 2026)
|
|
||||||
item(key = "header_$dateGroup") {
|
item(key = "header_$dateGroup") {
|
||||||
Text(
|
Text(
|
||||||
text = dateGroup,
|
dateGroup,
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items(items, key = { it.id }) { item ->
|
items(items, key = { it.id }) { item ->
|
||||||
HistoryItemRow(
|
HistoryItemRow(
|
||||||
item = item,
|
item = item,
|
||||||
|
|
@ -255,81 +180,50 @@ fun KaizenSidebar(
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// --- FOOTER Sektion: User Profile Card ---
|
// User card
|
||||||
// Clicking the card opens the brand settings screen cleanly (MVVM state)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(KaizenShapes.md)
|
||||||
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
|
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
|
||||||
.border(
|
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
|
||||||
1.dp,
|
|
||||||
if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f),
|
|
||||||
RoundedCornerShape(18.dp)
|
|
||||||
)
|
|
||||||
.clickable { onOpenSettings() }
|
.clickable { onOpenSettings() }
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// User Avatar bubble: Obsidian colored dynamic first-letter initials!
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.circle)
|
||||||
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
|
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = userName.firstOrNull()?.toString()?.uppercase() ?: "A",
|
userName.firstOrNull()?.toString()?.uppercase() ?: "A",
|
||||||
color = Color.White,
|
color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
|
|
||||||
Column(Modifier.weight(1f)) {
|
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(Icons.Rounded.Settings, "Einstellungen", tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Settings,
|
|
||||||
contentDescription = "Einstellungen",
|
|
||||||
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
// --- Logout: clears the encrypted token + returns to the login screen ---
|
// Logout
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(14.dp))
|
.clip(KaizenShapes.sm)
|
||||||
.clickable { onLogout() }
|
.clickable { onLogout() }
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.AutoMirrored.Rounded.Logout, "Abmelden", tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
|
||||||
imageVector = Icons.AutoMirrored.Rounded.Logout,
|
|
||||||
contentDescription = "Abmelden",
|
|
||||||
tint = cs.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(10.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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.pill)
|
||||||
.background(itemBg)
|
.background(itemBg)
|
||||||
.border(1.2.dp, itemBorder, CircleShape)
|
.border(1.2.dp, itemBorder, KaizenShapes.pill)
|
||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(horizontal = 16.dp, vertical = 11.dp),
|
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (item.pinned) {
|
if (item.pinned) {
|
||||||
Icon(
|
Icon(Icons.Rounded.Star, null, tint = Amber, modifier = Modifier.size(14.dp))
|
||||||
imageVector = Icons.Rounded.Star,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Amber,
|
|
||||||
modifier = Modifier.size(14.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = if (item.locked) "Gesperrter Chat" else item.title,
|
text = if (item.locked) "Gesperrter Chat" else item.title,
|
||||||
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground,
|
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,
|
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (item.locked) {
|
if (item.locked) {
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Icon(
|
Icon(Icons.Rounded.Lock, "Gesperrt", tint = Amber, modifier = Modifier.size(15.dp))
|
||||||
imageVector = Icons.Rounded.Lock,
|
|
||||||
contentDescription = "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.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import dev.kaizen.app.ui.effect.GlassSurface
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.text.BasicTextField
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
@ -81,53 +83,14 @@ fun SettingsCard(
|
||||||
) {
|
) {
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
||||||
val glassBg = remember(isDark) {
|
GlassSurface(
|
||||||
if (isDark) {
|
modifier = modifier.fillMaxWidth(),
|
||||||
Brush.verticalGradient(
|
shape = KaizenShapes.lg,
|
||||||
listOf(
|
tintAlpha = GlassTiers.card(isDark),
|
||||||
Color(0xFF1E293B).copy(alpha = 0.55f), // Slate 800 (55% opacity)
|
shadowStack = KaizenShadows.level2,
|
||||||
Color(0xFF0F172A).copy(alpha = 0.65f) // Slate 900 (65% opacity)
|
cornerRadius = 24.dp,
|
||||||
)
|
|
||||||
)
|
|
||||||
} 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)
|
|
||||||
) {
|
) {
|
||||||
// Standard Column provides standard ColumnScope receiver to the child layout content slot
|
Column(modifier = Modifier.fillMaxWidth().padding(18.dp)) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +115,7 @@ fun SettingsOptionRow(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(38.dp)
|
.size(38.dp)
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.circle)
|
||||||
.background(cs.onBackground.copy(alpha = 0.06f)),
|
.background(cs.onBackground.copy(alpha = 0.06f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|
@ -203,9 +166,9 @@ fun SettingsInputCapsule(
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.widthIn(max = 180.dp)
|
.widthIn(max = 180.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(KaizenShapes.sm)
|
||||||
.background(inputGlassBg)
|
.background(inputGlassBg)
|
||||||
.border(1.dp, inputGlassBorder, RoundedCornerShape(12.dp))
|
.border(1.dp, inputGlassBorder, KaizenShapes.sm)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
|
|
@ -262,8 +225,8 @@ fun ThemeOptionPill(
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.shadow(elevation = 4.dp, shape = CircleShape, clip = false)
|
.shadow(elevation = 4.dp, shape = KaizenShapes.circle, clip = false)
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.circle)
|
||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.border(
|
.border(
|
||||||
width = if (selected) 2.5.dp else 1.2.dp,
|
width = if (selected) 2.5.dp else 1.2.dp,
|
||||||
|
|
@ -272,10 +235,10 @@ fun ThemeOptionPill(
|
||||||
} else {
|
} else {
|
||||||
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.35f), Color.White.copy(alpha = 0.05f)))
|
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
|
.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
|
// Draw the perception gradient on Canvas
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
|
@ -317,8 +280,8 @@ fun AppearanceTogglePill(
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.shadow(elevation = if (selected) 4.dp else 0.dp, shape = CircleShape, clip = false)
|
.shadow(elevation = if (selected) 4.dp else 0.dp, shape = KaizenShapes.circle, clip = false)
|
||||||
.clip(CircleShape)
|
.clip(KaizenShapes.circle)
|
||||||
.background(
|
.background(
|
||||||
if (selected) {
|
if (selected) {
|
||||||
Amber.copy(alpha = 0.16f)
|
Amber.copy(alpha = 0.16f)
|
||||||
|
|
@ -333,7 +296,7 @@ fun AppearanceTogglePill(
|
||||||
} else {
|
} else {
|
||||||
glassBorder
|
glassBorder
|
||||||
},
|
},
|
||||||
shape = CircleShape
|
shape = KaizenShapes.circle
|
||||||
)
|
)
|
||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(horizontal = 16.dp, vertical = 9.dp),
|
.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.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.AutoAwesome
|
||||||
import androidx.compose.material.icons.rounded.Palette
|
import androidx.compose.material.icons.rounded.Palette
|
||||||
import androidx.compose.material.icons.rounded.Person
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -64,21 +64,6 @@ fun SettingsScreen(
|
||||||
val userName by viewModel.userName.collectAsState()
|
val userName by viewModel.userName.collectAsState()
|
||||||
val userEmail by viewModel.userEmail.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 {
|
MeshBackground {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -95,22 +80,16 @@ fun SettingsScreen(
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 24.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(
|
GlassSurface(
|
||||||
modifier = Modifier
|
shape = KaizenShapes.circle,
|
||||||
.shadow(elevation = 6.dp, shape = CircleShape, clip = false)
|
tintAlpha = GlassTiers.pill(isDark),
|
||||||
.clip(CircleShape)
|
shadowStack = KaizenShadows.level2,
|
||||||
.background(backBtnGlassBg)
|
cornerRadius = 22.dp,
|
||||||
.border(1.2.dp, backBtnGlassBorder, CircleShape)
|
modifier = Modifier.size(44.dp).clickable { onBack() },
|
||||||
.clickable { onBack() }
|
|
||||||
.size(44.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
imageVector = Icons.Rounded.ArrowBack,
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Zurück", tint = cs.onBackground, modifier = Modifier.size(20.dp))
|
||||||
contentDescription = "Zurück",
|
}
|
||||||
tint = cs.onBackground,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package dev.kaizen.app.ui.effect
|
package dev.kaizen.app.ui.effect
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
|
@ -10,10 +9,8 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
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.clip
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
@ -21,16 +18,19 @@ import androidx.compose.ui.unit.dp
|
||||||
import dev.kaizen.app.ui.shape.KaizenShapes
|
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)
|
* Android has no `backdrop-filter: blur()` — real refractive blur would require
|
||||||
* to blur the underlying composition through the surface, creating a real frosted-glass
|
* capturing and blurring the layer behind, which is not feasible in Compose's
|
||||||
* effect. On API 26–30, the blur is skipped and the surface uses a higher tint alpha
|
* rendering model. Instead, the glass effect is achieved through:
|
||||||
* (+0.10) to fake depth.
|
|
||||||
*
|
*
|
||||||
* The [tint] color gives the surface its identity (Obsidian vs white). Without it
|
* 1. Semi-transparent gradient tint (gives the surface body and color identity)
|
||||||
* the blur would show a wash of whatever's behind. [tintAlpha] controls the
|
* 2. Specular border highlight (top-bright, bottom-dim gradient border)
|
||||||
* "milkiness" — lower = more transparent/refractive, higher = more opaque/solid.
|
* 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.
|
* See DESIGN.md §4 for the full anatomy and opacity tiers.
|
||||||
*/
|
*/
|
||||||
|
|
@ -38,35 +38,51 @@ import dev.kaizen.app.ui.shape.KaizenShapes
|
||||||
fun GlassSurface(
|
fun GlassSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
shape: Shape = KaizenShapes.md,
|
shape: Shape = KaizenShapes.md,
|
||||||
tint: Color = MaterialTheme.colorScheme.surface,
|
|
||||||
tintAlpha: Float = 0.72f,
|
tintAlpha: Float = 0.72f,
|
||||||
blurRadius: Dp = 24.dp,
|
|
||||||
shadowStack: ShadowStack = KaizenShadows.level2,
|
shadowStack: ShadowStack = KaizenShadows.level2,
|
||||||
|
cornerRadius: Dp = 16.dp,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val isDark = isSystemInDarkTheme()
|
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 glassBg = remember(isDark, tintAlpha) {
|
||||||
val tintColor = remember(tint, effectiveTintAlpha) { tint.copy(alpha = effectiveTintAlpha) }
|
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 glassBorder = remember(isDark) {
|
||||||
val outlineAlpha = if (isDark) 0.12f else 0.06f
|
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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.kaizenShadow(stack = shadowStack, cornerRadius = 16.dp)
|
.kaizenShadow(stack = shadowStack, cornerRadius = cornerRadius)
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.then(
|
.background(glassBg)
|
||||||
if (supportsBlur) {
|
|
||||||
Modifier.blur(blurRadius, edgeTreatment = BlurredEdgeTreatment.Rectangle)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.drawBehind { drawRect(tintColor) }
|
|
||||||
.innerTopHighlight()
|
.innerTopHighlight()
|
||||||
.border(1.dp, outlineColor.copy(alpha = outlineAlpha), shape),
|
.border(1.2.dp, glassBorder, shape),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -76,24 +92,11 @@ fun GlassSurface(
|
||||||
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
|
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
|
||||||
*/
|
*/
|
||||||
object GlassTiers {
|
object GlassTiers {
|
||||||
/** Sidebar — most translucent, strongest refraction */
|
|
||||||
fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f
|
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
|
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
|
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
|
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
|
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
|
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
|
fun sheet(isDark: Boolean) = if (isDark) 0.82f else 0.85f
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue