kaizen-app/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt
Bruno Deanoz f855a26b31 fix: sidebar overlapping status bar on notch devices
statusBarsPadding() was on the inner Column (only pushed content down),
but the GlassSurface itself started at a fixed 12dp from the top edge —
on devices with tall notches (S25 Ultra) the sidebar header sat behind
the system clock/icons. Moved statusBarsPadding() to the GlassSurface
modifier so the entire glass panel starts below the status bar.
2026-06-23 01:41:07 +02:00

552 lines
24 KiB
Kotlin

package dev.kaizen.app.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
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.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.effect.kaizenShadow
import dev.kaizen.app.ui.effect.innerTopHighlight
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.R
import dev.kaizen.app.ui.theme.LocalKaizenAccent
import androidx.compose.ui.res.stringResource
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import dev.kaizen.app.ui.icon.KaizenIcons
private data class DateLabels(val today: String, val yesterday: String, val earlier: String, val pinned: String)
private fun dateLabel(updatedAt: String?, labels: DateLabels): String {
val instant = runCatching { Instant.parse(updatedAt) }.getOrNull() ?: return labels.earlier
val date = instant.atZone(ZoneId.systemDefault()).toLocalDate()
val today = LocalDate.now()
return when (date) {
today -> labels.today
today.minusDays(1) -> labels.yesterday
else -> date.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.getDefault())).uppercase()
}
}
private fun groupConversations(conversations: List<ConversationSummary>, labels: DateLabels): Map<String, List<ConversationSummary>> {
val groups = LinkedHashMap<String, MutableList<ConversationSummary>>()
conversations.filter { it.pinned }.takeIf { it.isNotEmpty() }?.let { groups[labels.pinned] = it.toMutableList() }
conversations.filterNot { it.pinned }.forEach {
groups.getOrPut(dateLabel(it.updatedAt, labels)) { mutableListOf() }.add(it)
}
return groups
}
sealed interface SidebarAction {
data class Rename(val id: String, val newTitle: String) : SidebarAction
data class Delete(val id: String) : SidebarAction
data class TogglePin(val id: String, val pinned: Boolean) : SidebarAction
data class ToggleLock(val id: String, val locked: Boolean) : SidebarAction
data class GenerateTitle(val id: String) : SidebarAction
data class Duplicate(val id: String) : SidebarAction
}
@Composable
fun KaizenSidebar(
userName: String,
conversations: List<ConversationSummary>,
currentId: String?,
onClose: () -> Unit,
onNewChat: () -> Unit,
onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit,
onLogout: () -> Unit,
onAction: (SidebarAction) -> Unit = {},
modifier: Modifier = Modifier
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameText by remember { mutableStateOf("") }
var searchQuery by remember { mutableStateOf("") }
GlassSurface(
modifier = modifier
.fillMaxHeight()
.statusBarsPadding()
.width(300.dp)
.padding(start = 12.dp, top = 8.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg,
tintAlpha = if (isDark) 0.80f else 0.86f,
shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp,
) {
Column(
Modifier
.fillMaxSize()
.padding(16.dp)
) {
// ── Header ──
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(R.string.sidebar_brand),
color = cs.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Box(
modifier = Modifier
.size(34.dp)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() },
contentAlignment = Alignment.Center
) {
Icon(KaizenIcons.Pencil, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(17.dp))
}
}
Spacer(Modifier.height(14.dp))
// ── Search (functional) ──
val searchBorder = remember(isDark) {
if (isDark) Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
) else Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.04f))
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.pill)
.border(0.8.dp, searchBorder, KaizenShapes.pill)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(KaizenIcons.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.40f), modifier = Modifier.size(15.dp))
Spacer(Modifier.width(8.dp))
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = TextStyle(
color = cs.onBackground,
fontSize = 13.sp,
),
cursorBrush = SolidColor(cs.onSurfaceVariant),
decorationBox = { innerTextField ->
Box {
if (searchQuery.isEmpty()) {
Text(
stringResource(R.string.chat_search),
color = cs.onSurfaceVariant.copy(alpha = 0.40f),
fontSize = 13.sp,
)
}
innerTextField()
}
},
modifier = Modifier.weight(1f),
)
if (searchQuery.isNotEmpty()) {
Icon(
KaizenIcons.X,
null,
tint = cs.onSurfaceVariant.copy(alpha = 0.40f),
modifier = Modifier
.size(14.dp)
.clickable { searchQuery = "" },
)
}
}
Spacer(Modifier.height(18.dp))
// ── Conversation list ──
val filteredConversations = remember(conversations, searchQuery) {
if (searchQuery.isBlank()) conversations
else conversations.filter { it.title.contains(searchQuery, ignoreCase = true) }
}
Box(Modifier.weight(1f).fillMaxWidth()) {
if (filteredConversations.isEmpty()) {
Text(
stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 13.sp,
modifier = Modifier.padding(start = 8.dp, top = 8.dp)
)
} else {
val labels = DateLabels(
today = stringResource(R.string.sidebar_today),
yesterday = stringResource(R.string.sidebar_yesterday),
earlier = stringResource(R.string.sidebar_earlier),
pinned = stringResource(R.string.sidebar_pinned),
)
val grouped = remember(filteredConversations, labels) { groupConversations(filteredConversations, labels) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
grouped.forEach { (dateGroup, items) ->
item(key = "header_$dateGroup") {
Text(
dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.35f),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
)
}
items(items, key = { it.id }) { item ->
HistoryItemRow(
item = item,
selected = item.id == currentId,
onClick = { onSelectConversation(item) },
onRename = {
renameTarget = item
renameText = item.title
},
onDelete = { deleteTarget = item },
onTogglePin = { onAction(SidebarAction.TogglePin(item.id, !item.pinned)) },
onToggleLock = { onAction(SidebarAction.ToggleLock(item.id, !item.locked)) },
onGenerateTitle = { onAction(SidebarAction.GenerateTitle(item.id)) },
onDuplicate = { onAction(SidebarAction.Duplicate(item.id)) },
)
}
}
}
}
}
Spacer(Modifier.height(12.dp))
// ── User card ──
val cardBorder = remember(isDark) {
if (isDark) Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.03f))
) else Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.04f))
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 12.dp)
.background(if (isDark) Color(0x14FFFFFF) else Color(0x0A000000))
.border(0.8.dp, cardBorder, KaizenShapes.md)
.innerTopHighlight()
.clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(30.dp)
.clip(KaizenShapes.circle)
.background(
Brush.verticalGradient(
listOf(accent.primary.copy(alpha = 0.8f), accent.primary.copy(alpha = 0.6f))
)
),
contentAlignment = Alignment.Center
) {
Text(
userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.width(10.dp))
Text(
userName,
color = cs.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Icon(
KaizenIcons.Settings,
stringResource(R.string.sidebar_settings),
tint = cs.onSurfaceVariant.copy(alpha = 0.45f),
modifier = Modifier.size(17.dp),
)
Spacer(Modifier.width(6.dp))
Box(
modifier = Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable { onLogout() },
contentAlignment = Alignment.Center,
) {
Icon(
KaizenIcons.LogOut,
stringResource(R.string.sidebar_logout),
tint = cs.onSurfaceVariant.copy(alpha = 0.45f),
modifier = Modifier.size(15.dp),
)
}
}
}
}
// Delete confirmation dialog
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(R.string.sidebar_delete)) },
text = { Text(stringResource(R.string.sidebar_delete_confirm)) },
confirmButton = {
TextButton(onClick = {
onAction(SidebarAction.Delete(target.id))
deleteTarget = null
}) {
Text(stringResource(R.string.sidebar_confirm), color = Color(0xFFEF4444))
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
}
// Rename dialog
renameTarget?.let { target ->
val focusManager = LocalFocusManager.current
AlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text(stringResource(R.string.sidebar_rename_title)) },
text = {
BasicTextField(
value = renameText,
onValueChange = { renameText = it },
singleLine = true,
textStyle = TextStyle(
color = cs.onBackground,
fontSize = 15.sp,
),
cursorBrush = SolidColor(cs.onBackground),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
focusManager.clearFocus()
if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
}
renameTarget = null
}),
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 12.dp),
)
},
confirmButton = {
TextButton(onClick = {
if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
}
renameTarget = null
}) {
Text(stringResource(R.string.sidebar_save))
}
},
dismissButton = {
TextButton(onClick = { renameTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
}
}
@Composable
private fun HistoryItemRow(
item: ConversationSummary,
selected: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit,
onToggleLock: () -> Unit,
onGenerateTitle: () -> Unit,
onDuplicate: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
var showMenu by remember { mutableStateOf(false) }
val activeBorder = remember(isDark, accent) {
if (isDark) Brush.verticalGradient(
listOf(accent.primary.copy(alpha = 0.25f), accent.primary.copy(alpha = 0.06f))
) else Brush.verticalGradient(
listOf(accent.primary.copy(alpha = 0.20f), accent.primary.copy(alpha = 0.05f))
)
}
val inactiveBorder = remember(isDark) {
if (isDark) Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.07f), Color.White.copy(alpha = 0.02f))
) else Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.35f), Color.Black.copy(alpha = 0.03f))
)
}
Box(modifier = Modifier.padding(horizontal = 2.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.then(
if (selected) {
Modifier
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 16.dp)
.background(accent.primary.copy(alpha = if (isDark) 0.12f else 0.08f))
.border(0.8.dp, activeBorder, KaizenShapes.md)
} else {
Modifier
.background(if (isDark) Color(0x08FFFFFF) else Color(0x05000000))
.border(0.5.dp, inactiveBorder, KaizenShapes.md)
}
)
.clickable { onClick() }
.padding(start = 12.dp, end = 4.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.pinned) {
Icon(KaizenIcons.Star, null, tint = accent.primary.copy(alpha = 0.7f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(6.dp))
}
Text(
text = if (item.locked) stringResource(R.string.chat_locked) else item.title,
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = if (selected) 0.95f else 0.80f),
fontSize = 13.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (item.locked) {
Icon(KaizenIcons.Lock, null, tint = accent.primary.copy(alpha = 0.5f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(2.dp))
}
Box(
modifier = Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable { showMenu = true },
contentAlignment = Alignment.Center,
) {
Icon(
KaizenIcons.MoreVertical,
stringResource(R.string.sidebar_more),
tint = cs.onSurfaceVariant.copy(alpha = 0.30f),
modifier = Modifier.size(15.dp),
)
}
}
androidx.compose.material3.DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
containerColor = if (isDark) Color(0xFF1A2030) else Color(0xFFF8F9FB),
shadowElevation = 16.dp,
shape = KaizenShapes.md,
) {
if (!item.locked) {
MenuRow(KaizenIcons.Pencil, stringResource(R.string.sidebar_rename), cs.onBackground) { showMenu = false; onRename() }
}
if (!item.titleGenerated) {
MenuRow(KaizenIcons.Sparkles, stringResource(R.string.sidebar_generate_title), cs.onBackground) { showMenu = false; onGenerateTitle() }
}
MenuRow(KaizenIcons.Pin, stringResource(if (item.pinned) R.string.sidebar_unpin else R.string.sidebar_pin), cs.onBackground) { showMenu = false; onTogglePin() }
MenuRow(KaizenIcons.Copy, stringResource(R.string.sidebar_duplicate), cs.onBackground) { showMenu = false; onDuplicate() }
MenuRow(
if (item.locked) KaizenIcons.LockOpen else KaizenIcons.Lock,
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
cs.onBackground,
) { showMenu = false; onToggleLock() }
// Separator
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp)
.background(if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f))
)
MenuRow(KaizenIcons.Trash, stringResource(R.string.sidebar_delete), Color(0xFFEF4444)) { showMenu = false; onDelete() }
}
}
}
@Composable
private fun MenuRow(icon: ImageVector, label: String, tint: Color, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, tint = tint.copy(alpha = 0.6f), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(12.dp))
Text(label, color = tint.copy(alpha = 0.9f), fontSize = 13.5.sp, fontWeight = FontWeight.Medium)
}
}