sidebar redesign + markdown typography overhaul
Sidebar: increase glass opacity (0.92/0.88), flat conversation rows instead of pill shapes, 3-dot context menu (rename/delete/pin/lock), smaller profile avatar (30dp), logout integrated into user card. Add KaizenApi.patchConversation + deleteConversation for CRUD. Markdown: increase line-height from 23sp to 26sp (×1.625 ratio), double paragraph spacing (6dp→12dp), generous heading margins (18dp top, 8dp bottom), wider list item spacing — matches Gemini- level readability.
This commit is contained in:
parent
3986443a56
commit
f3d8154052
7 changed files with 371 additions and 97 deletions
|
|
@ -409,7 +409,32 @@ fun ChatScreen(
|
|||
newChat()
|
||||
scope.launch { drawerState.close() }
|
||||
session.logout()
|
||||
}
|
||||
},
|
||||
onAction = { action ->
|
||||
val cfg = session.config ?: return@KaizenSidebar
|
||||
haptics.tick()
|
||||
scope.launch {
|
||||
when (action) {
|
||||
is SidebarAction.Rename -> {
|
||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("title" to action.newTitle))
|
||||
refreshConversations()
|
||||
}
|
||||
is SidebarAction.Delete -> {
|
||||
KaizenApi.deleteConversation(cfg.baseUrl, cfg.token, action.id)
|
||||
if (conversationId == action.id) newChat()
|
||||
refreshConversations()
|
||||
}
|
||||
is SidebarAction.TogglePin -> {
|
||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("pinned" to action.pinned))
|
||||
refreshConversations()
|
||||
}
|
||||
is SidebarAction.ToggleLock -> {
|
||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked))
|
||||
refreshConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer
|
||||
|
|
|
|||
|
|
@ -363,20 +363,20 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
|||
val isLast = idx == blocks.lastIndex
|
||||
when (block) {
|
||||
is MdBlock.Heading -> {
|
||||
if (idx > 0) Spacer(Modifier.height(8.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(18.dp))
|
||||
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
|
||||
Text(
|
||||
text = rendered,
|
||||
color = cs.onBackground,
|
||||
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = when (block.level) { 1 -> 28.sp; 2 -> 25.sp; else -> 23.sp },
|
||||
lineHeight = when (block.level) { 1 -> 30.sp; 2 -> 27.sp; else -> 24.sp },
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
is MdBlock.CodeFence -> {
|
||||
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -416,13 +416,13 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
|||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
is MdBlock.ListBlock -> {
|
||||
if (idx > 0) Spacer(Modifier.height(4.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(8.dp))
|
||||
block.items.forEachIndexed { itemIdx, item ->
|
||||
Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 3.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) {
|
||||
val bullet = if (item.ordered) "${item.index}." else "•"
|
||||
Text(
|
||||
bullet,
|
||||
|
|
@ -435,27 +435,27 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
|||
text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
|
||||
color = cs.onBackground,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 23.sp,
|
||||
lineHeight = 26.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
is MdBlock.HorizontalRule -> {
|
||||
if (idx > 0) Spacer(Modifier.height(8.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(cs.onSurfaceVariant.copy(alpha = 0.25f))
|
||||
.background(cs.onSurfaceVariant.copy(alpha = 0.20f))
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
}
|
||||
|
||||
is MdBlock.Blockquote -> {
|
||||
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -474,21 +474,21 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
|
|||
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg),
|
||||
color = cs.onSurfaceVariant,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
is MdBlock.Paragraph -> {
|
||||
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||
if (idx > 0) Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
|
||||
color = cs.onBackground,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 23.sp,
|
||||
lineHeight = 26.sp,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,25 +19,43 @@ 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.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Logout
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Lock
|
||||
import androidx.compose.material.icons.rounded.LockOpen
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.PushPin
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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.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
|
||||
|
|
@ -76,6 +94,13 @@ private fun groupConversations(conversations: List<ConversationSummary>, labels:
|
|||
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
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun KaizenSidebar(
|
||||
userName: String,
|
||||
|
|
@ -86,18 +111,23 @@ fun KaizenSidebar(
|
|||
onSelectConversation: (ConversationSummary) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onAction: (SidebarAction) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||
var renameText by remember { mutableStateOf("") }
|
||||
|
||||
GlassSurface(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(300.dp)
|
||||
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
|
||||
shape = KaizenShapes.lg,
|
||||
tintAlpha = GlassTiers.sidebar(isDark),
|
||||
tintAlpha = if (isDark) 0.88f else 0.92f,
|
||||
shadowStack = KaizenShadows.level3,
|
||||
cornerRadius = 24.dp,
|
||||
) {
|
||||
|
|
@ -113,46 +143,50 @@ fun KaizenSidebar(
|
|||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.sidebar_brand), color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringResource(R.string.sidebar_brand),
|
||||
color = cs.onBackground,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.size(34.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
|
||||
.clickable { onNewChat() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
// Search
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.sm)
|
||||
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
|
||||
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
.clip(KaizenShapes.pill)
|
||||
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000))
|
||||
.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp)
|
||||
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.45f), fontSize = 13.sp)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
// Conversation list
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (conversations.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.chat_no_conversations),
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp)
|
||||
)
|
||||
} else {
|
||||
val labels = DateLabels(
|
||||
|
|
@ -164,16 +198,16 @@ fun KaizenSidebar(
|
|||
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
grouped.forEach { (dateGroup, items) ->
|
||||
item(key = "header_$dateGroup") {
|
||||
Text(
|
||||
dateGroup,
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
items(items, key = { it.id }) { item ->
|
||||
|
|
@ -181,6 +215,13 @@ fun KaizenSidebar(
|
|||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -188,99 +229,242 @@ fun KaizenSidebar(
|
|||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// User card
|
||||
// User card — compact footer with settings + logout
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.md)
|
||||
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
|
||||
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
|
||||
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000))
|
||||
.clickable { onOpenSettings() }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.size(30.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
userName.firstOrNull()?.toString()?.uppercase() ?: "A",
|
||||
color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold
|
||||
color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
userName,
|
||||
color = cs.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
Icons.Rounded.Settings,
|
||||
stringResource(R.string.sidebar_settings),
|
||||
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(KaizenShapes.circle)
|
||||
.clickable { onLogout() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.Logout,
|
||||
stringResource(R.string.sidebar_logout),
|
||||
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Icon(Icons.Rounded.Settings, stringResource(R.string.sidebar_settings), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Logout
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.sm)
|
||||
.clickable { onLogout() }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Rounded.Logout, stringResource(R.string.sidebar_logout), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
private fun HistoryItemRow(
|
||||
item: ConversationSummary,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onTogglePin: () -> Unit,
|
||||
onToggleLock: () -> Unit,
|
||||
) {
|
||||
val cs = MaterialTheme.colorScheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val accent = LocalKaizenAccent.current
|
||||
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val itemBg = when {
|
||||
selected -> accent.primary.copy(alpha = 0.14f)
|
||||
isDark -> Color(0x0AFFFFFF)
|
||||
else -> Color(0x05000000)
|
||||
}
|
||||
val itemBorder = when {
|
||||
selected -> accent.primary.copy(alpha = 0.35f)
|
||||
isDark -> Color.White.copy(alpha = 0.04f)
|
||||
else -> Color.Black.copy(alpha = 0.03f)
|
||||
selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.pill)
|
||||
.background(itemBg)
|
||||
.border(1.2.dp, itemBorder, KaizenShapes.pill)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (item.pinned) {
|
||||
Icon(Icons.Rounded.Star, null, tint = accent.primary, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(KaizenShapes.sm)
|
||||
.background(itemBg)
|
||||
.clickable { onClick() }
|
||||
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (item.pinned) {
|
||||
Icon(Icons.Rounded.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 = 0.85f),
|
||||
fontSize = 13.5.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(Icons.Rounded.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(
|
||||
Icons.Rounded.MoreVert,
|
||||
stringResource(R.string.sidebar_more),
|
||||
tint = cs.onSurfaceVariant.copy(alpha = 0.35f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (item.locked) stringResource(R.string.chat_locked) else item.title,
|
||||
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (item.locked) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = accent.primary, modifier = Modifier.size(15.dp))
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
if (!item.locked) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.sidebar_rename), fontSize = 14.sp) },
|
||||
onClick = { showMenu = false; onRename() },
|
||||
leadingIcon = { Icon(Icons.Rounded.Edit, null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(if (item.pinned) R.string.sidebar_unpin else R.string.sidebar_pin),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
onClick = { showMenu = false; onTogglePin() },
|
||||
leadingIcon = { Icon(Icons.Rounded.PushPin, null, modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
onClick = { showMenu = false; onToggleLock() },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (item.locked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
||||
null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.sidebar_delete), fontSize = 14.sp, color = Color(0xFFEF4444)) },
|
||||
onClick = { showMenu = false; onDelete() },
|
||||
leadingIcon = { Icon(Icons.Rounded.Delete, null, tint = Color(0xFFEF4444), modifier = Modifier.size(18.dp)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,6 +316,47 @@ object KaizenApi {
|
|||
}
|
||||
}
|
||||
|
||||
/** PATCH /api/v1/conversations/[id] — update title, locked, pinned. */
|
||||
suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map<String, Any?>): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val payload = json.encodeToString(kotlinx.serialization.json.JsonObject(
|
||||
body.mapValues { (_, v) ->
|
||||
when (v) {
|
||||
is String -> kotlinx.serialization.json.JsonPrimitive(v)
|
||||
is Boolean -> kotlinx.serialization.json.JsonPrimitive(v)
|
||||
else -> kotlinx.serialization.json.JsonNull
|
||||
}
|
||||
}
|
||||
)).toRequestBody(jsonMedia)
|
||||
val req = Request.Builder()
|
||||
.url("$baseUrl/api/v1/conversations/$id")
|
||||
.patch(payload)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
try {
|
||||
client.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "patchConversation($id): ${e.javaClass.simpleName}: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/conversations/[id] — permanently remove a conversation. */
|
||||
suspend fun deleteConversation(baseUrl: String, token: String, id: String): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val req = Request.Builder()
|
||||
.url("$baseUrl/api/v1/conversations/$id")
|
||||
.delete()
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
try {
|
||||
client.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteConversation($id): ${e.javaClass.simpleName}: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
|
||||
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ fun GlassSurface(
|
|||
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
|
||||
*/
|
||||
object GlassTiers {
|
||||
fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f
|
||||
fun sidebar(isDark: Boolean) = if (isDark) 0.88f else 0.92f
|
||||
fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
|
||||
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
|
||||
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@
|
|||
<string name="sidebar_earlier">EARLIER</string>
|
||||
<string name="sidebar_logout">Sign Out</string>
|
||||
<string name="sidebar_settings">Settings</string>
|
||||
<string name="sidebar_rename">Rename</string>
|
||||
<string name="sidebar_delete">Delete</string>
|
||||
<string name="sidebar_pin">Pin</string>
|
||||
<string name="sidebar_unpin">Unpin</string>
|
||||
<string name="sidebar_lock">Lock</string>
|
||||
<string name="sidebar_unlock">Unlock</string>
|
||||
<string name="sidebar_more">More</string>
|
||||
<string name="sidebar_rename_title">Rename Chat</string>
|
||||
<string name="sidebar_delete_confirm">Permanently delete this chat?</string>
|
||||
<string name="sidebar_cancel">Cancel</string>
|
||||
<string name="sidebar_confirm">Confirm</string>
|
||||
<string name="sidebar_save">Save</string>
|
||||
|
||||
<!-- Model sheet -->
|
||||
<string name="model_sheet_title">Select Model</string>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,18 @@
|
|||
<string name="sidebar_earlier">FRÜHER</string>
|
||||
<string name="sidebar_logout">Abmelden</string>
|
||||
<string name="sidebar_settings">Einstellungen</string>
|
||||
<string name="sidebar_rename">Umbenennen</string>
|
||||
<string name="sidebar_delete">Löschen</string>
|
||||
<string name="sidebar_pin">Anheften</string>
|
||||
<string name="sidebar_unpin">Lösen</string>
|
||||
<string name="sidebar_lock">Sperren</string>
|
||||
<string name="sidebar_unlock">Entsperren</string>
|
||||
<string name="sidebar_more">Mehr</string>
|
||||
<string name="sidebar_rename_title">Chat umbenennen</string>
|
||||
<string name="sidebar_delete_confirm">Chat endgültig löschen?</string>
|
||||
<string name="sidebar_cancel">Abbrechen</string>
|
||||
<string name="sidebar_confirm">Bestätigen</string>
|
||||
<string name="sidebar_save">Speichern</string>
|
||||
|
||||
<!-- Model sheet -->
|
||||
<string name="model_sheet_title">Modell auswählen</string>
|
||||
|
|
|
|||
Loading…
Reference in a new issue