Android: ChatGPT-style chat composer with integrated PTT
Redesigned ChatComposer with Telegram/ChatGPT-style layout: - Hamburger menu on left (session, thinking level, attach, refresh) - Rounded text input field in center - Right button transforms: - Empty: Mini orb (PTT) with mic icon - Has text: Send button (arrow up) - Running: Stop button - Haptic feedback on orb tap - Compact connection status pill below - Talk overlay shows centered when voice active
This commit is contained in:
parent
9c059906fe
commit
40a5b0ce1e
@ -267,43 +267,16 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat overlay + PTT orb at bottom
|
// PTT orb overlay (only show when talk mode is active, for visual feedback)
|
||||||
Popup(alignment = Alignment.BottomCenter, properties = PopupProperties(focusable = false)) {
|
if (talkEnabled) {
|
||||||
Column(
|
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
|
||||||
.padding(bottom = 24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
// Chat messages above the orb
|
|
||||||
val messages by viewModel.chatMessages.collectAsState()
|
|
||||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
|
||||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
|
||||||
val streamingText by viewModel.chatStreamingAssistantText.collectAsState()
|
|
||||||
|
|
||||||
if (messages.isNotEmpty() || pendingRunCount > 0 || pendingToolCalls.isNotEmpty() || !streamingText.isNullOrBlank()) {
|
|
||||||
com.clawdbot.android.ui.chat.ChatMessageListCard(
|
|
||||||
messages = messages,
|
|
||||||
pendingRunCount = pendingRunCount,
|
|
||||||
pendingToolCalls = pendingToolCalls,
|
|
||||||
streamingAssistantText = streamingText,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f, fill = false)
|
|
||||||
.heightIn(max = 400.dp)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PTT orb
|
|
||||||
TalkOrbOverlay(
|
TalkOrbOverlay(
|
||||||
seamColor = seamColor,
|
seamColor = seamColor,
|
||||||
statusText = talkStatusText,
|
statusText = talkStatusText,
|
||||||
isListening = talkIsListening,
|
isListening = talkIsListening,
|
||||||
isSpeaking = talkIsSpeaking,
|
isSpeaking = talkIsSpeaking,
|
||||||
isActive = talkEnabled,
|
isActive = true,
|
||||||
onTap = { viewModel.setTalkEnabled(!talkEnabled) },
|
onTap = { viewModel.setTalkEnabled(false) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,7 +79,7 @@ fun TalkOrbOverlay(
|
|||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(200.dp)
|
.size(280.dp)
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null, // No ripple - the orb itself is the feedback
|
indication = null, // No ripple - the orb itself is the feedback
|
||||||
|
|||||||
@ -1,31 +1,47 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
package com.clawdbot.android.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
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.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
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.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 androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -36,8 +52,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
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.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -50,6 +73,8 @@ fun ChatComposer(
|
|||||||
pendingRunCount: Int,
|
pendingRunCount: Int,
|
||||||
errorText: String?,
|
errorText: String?,
|
||||||
attachments: List<PendingImageAttachment>,
|
attachments: List<PendingImageAttachment>,
|
||||||
|
seamColor: Color = Color(0xFF3B82F6),
|
||||||
|
talkEnabled: Boolean = false,
|
||||||
onPickImages: () -> Unit,
|
onPickImages: () -> Unit,
|
||||||
onRemoveAttachment: (id: String) -> Unit,
|
onRemoveAttachment: (id: String) -> Unit,
|
||||||
onSetThinkingLevel: (level: String) -> Unit,
|
onSetThinkingLevel: (level: String) -> Unit,
|
||||||
@ -57,132 +82,264 @@ fun ChatComposer(
|
|||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onAbort: () -> Unit,
|
onAbort: () -> Unit,
|
||||||
onSend: (text: String) -> Unit,
|
onSend: (text: String) -> Unit,
|
||||||
|
onToggleTalk: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var input by rememberSaveable { mutableStateOf("") }
|
var input by rememberSaveable { mutableStateOf("") }
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||||
var showSessionMenu by remember { mutableStateOf(false) }
|
var showSessionMenu by remember { mutableStateOf(false) }
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||||
val currentSessionLabel =
|
val currentSessionLabel =
|
||||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||||
|
|
||||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
val hasText = input.trim().isNotEmpty()
|
||||||
|
val canSend = pendingRunCount == 0 && (hasText || attachments.isNotEmpty()) && healthOk
|
||||||
|
|
||||||
Surface(
|
Column(
|
||||||
shape = MaterialTheme.shapes.large,
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
tonalElevation = 0.dp,
|
|
||||||
shadowElevation = 0.dp,
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
// Error text if any
|
||||||
|
if (!errorText.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = errorText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments strip
|
||||||
|
if (attachments.isNotEmpty()) {
|
||||||
|
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main input row: [Menu] [TextField] [Orb/Send]
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().padding(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
// Menu button
|
||||||
Box {
|
Box {
|
||||||
FilledTonalButton(
|
IconButton(
|
||||||
onClick = { showSessionMenu = true },
|
onClick = { showMenu = true },
|
||||||
contentPadding = ButtonDefaults.ContentPadding,
|
modifier = Modifier.size(44.dp),
|
||||||
) {
|
) {
|
||||||
Text("Session: $currentSessionLabel")
|
Icon(
|
||||||
|
Icons.Default.Menu,
|
||||||
|
contentDescription = "Menu",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
DropdownMenu(
|
||||||
for (entry in sessionOptions) {
|
expanded = showMenu,
|
||||||
DropdownMenuItem(
|
onDismissRequest = { showMenu = false },
|
||||||
text = { Text(entry.displayName ?: entry.key) },
|
|
||||||
onClick = {
|
|
||||||
onSelectSession(entry.key)
|
|
||||||
showSessionMenu = false
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
if (entry.key == sessionKey) {
|
|
||||||
Text("✓")
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box {
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { showThinkingMenu = true },
|
|
||||||
contentPadding = ButtonDefaults.ContentPadding,
|
|
||||||
) {
|
) {
|
||||||
Text("Thinking: ${thinkingLabel(thinkingLevel)}")
|
// Session selector
|
||||||
}
|
DropdownMenuItem(
|
||||||
|
text = { Text("Session: $currentSessionLabel") },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
showSessionMenu = true
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||||
|
)
|
||||||
|
|
||||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
// Thinking level
|
||||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
DropdownMenuItem(
|
||||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
text = { Text("Thinking: ${thinkingLabel(thinkingLevel)}") },
|
||||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
onClick = {
|
||||||
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
showMenu = false
|
||||||
|
showThinkingMenu = true
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Text("🧠", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Attach file
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Attach image") },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onPickImages()
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(Icons.Default.AttachFile, contentDescription = null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Refresh") },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onRefresh()
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
// Session menu (separate dropdown)
|
||||||
|
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||||
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
for (entry in sessionOptions) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry.displayName ?: entry.key) },
|
||||||
|
onClick = {
|
||||||
|
onSelectSession(entry.key)
|
||||||
|
showSessionMenu = false
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (entry.key == sessionKey) Text("✓") else Spacer(Modifier.width(10.dp))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) {
|
// Thinking menu (separate dropdown)
|
||||||
Icon(Icons.Default.AttachFile, contentDescription = "Add image")
|
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||||
|
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
|
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
|
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
|
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (attachments.isNotEmpty()) {
|
// Text field
|
||||||
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
Box(
|
||||||
}
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
OutlinedTextField(
|
.padding(horizontal = 4.dp),
|
||||||
value = input,
|
) {
|
||||||
onValueChange = { input = it },
|
BasicTextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
value = input,
|
||||||
placeholder = { Text("Message Clawd…") },
|
onValueChange = { input = it },
|
||||||
minLines = 2,
|
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||||
maxLines = 6,
|
textStyle = TextStyle(
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 16.sp,
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
),
|
||||||
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
maxLines = 4,
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box {
|
||||||
|
if (input.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Message Clawd…",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right button: Abort / Send / Orb (PTT)
|
||||||
if (pendingRunCount > 0) {
|
if (pendingRunCount > 0) {
|
||||||
FilledTonalIconButton(
|
// Abort button when running
|
||||||
|
IconButton(
|
||||||
onClick = onAbort,
|
onClick = onAbort,
|
||||||
colors =
|
modifier = Modifier.size(44.dp),
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = Color(0x33E74C3C),
|
|
||||||
contentColor = Color(0xFFE74C3C),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Stop, contentDescription = "Abort")
|
Icon(
|
||||||
|
Icons.Default.Stop,
|
||||||
|
contentDescription = "Abort",
|
||||||
|
tint = Color(0xFFE74C3C),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else if (hasText || attachments.isNotEmpty()) {
|
||||||
FilledTonalIconButton(onClick = {
|
// Send button when there's content
|
||||||
val text = input
|
FilledTonalIconButton(
|
||||||
input = ""
|
onClick = {
|
||||||
onSend(text)
|
val text = input
|
||||||
}, enabled = canSend) {
|
input = ""
|
||||||
|
onSend(text)
|
||||||
|
},
|
||||||
|
enabled = canSend,
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = seamColor,
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
|
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Mini orb (PTT) when empty
|
||||||
|
MiniOrb(
|
||||||
|
seamColor = seamColor,
|
||||||
|
isActive = talkEnabled,
|
||||||
|
onClick = {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
onToggleTalk()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!errorText.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = errorText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
maxLines = 2,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connection status pill (compact)
|
||||||
|
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MiniOrb(
|
||||||
|
seamColor: Color,
|
||||||
|
isActive: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val alpha = if (isActive) 1f else 0.6f
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onClick,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.size(36.dp)) {
|
||||||
|
val center = this.center
|
||||||
|
val radius = size.minDimension / 2
|
||||||
|
|
||||||
|
drawCircle(
|
||||||
|
brush = Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
seamColor.copy(alpha = 0.9f * alpha),
|
||||||
|
seamColor.copy(alpha = 0.4f * alpha),
|
||||||
|
Color.Black.copy(alpha = 0.3f * alpha),
|
||||||
|
),
|
||||||
|
center = center,
|
||||||
|
radius = radius * 1.2f,
|
||||||
|
),
|
||||||
|
radius = radius,
|
||||||
|
center = center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mic icon overlay
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Mic,
|
||||||
|
contentDescription = if (isActive) "Listening" else "Tap to talk",
|
||||||
|
tint = Color.White.copy(alpha = alpha),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,19 +347,18 @@ fun ChatComposer(
|
|||||||
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.size(7.dp),
|
modifier = Modifier.size(6.dp),
|
||||||
shape = androidx.compose.foundation.shape.CircleShape,
|
shape = CircleShape,
|
||||||
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||||
) {}
|
) {}
|
||||||
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
|
|
||||||
Text(
|
Text(
|
||||||
if (healthOk) "Connected" else "Connecting…",
|
if (healthOk) "Connected" else "Connecting…",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@ -276,9 +432,9 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
|
|||||||
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
) {
|
) {
|
||||||
Text("×")
|
Text("×", fontSize = 14.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||||
val sessions by viewModel.chatSessions.collectAsState()
|
val sessions by viewModel.chatSessions.collectAsState()
|
||||||
|
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
|
||||||
|
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
||||||
|
val seamColor = remember(seamColorArgb) { androidx.compose.ui.graphics.Color(seamColorArgb) }
|
||||||
|
|
||||||
LaunchedEffect(mainSessionKey) {
|
LaunchedEffect(mainSessionKey) {
|
||||||
viewModel.loadChat(mainSessionKey)
|
viewModel.loadChat(mainSessionKey)
|
||||||
@ -92,6 +95,8 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
pendingRunCount = pendingRunCount,
|
pendingRunCount = pendingRunCount,
|
||||||
errorText = errorText,
|
errorText = errorText,
|
||||||
attachments = attachments,
|
attachments = attachments,
|
||||||
|
seamColor = seamColor,
|
||||||
|
talkEnabled = talkEnabled,
|
||||||
onPickImages = { pickImages.launch("image/*") },
|
onPickImages = { pickImages.launch("image/*") },
|
||||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||||
@ -101,6 +106,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
viewModel.refreshChatSessions(limit = 200)
|
viewModel.refreshChatSessions(limit = 200)
|
||||||
},
|
},
|
||||||
onAbort = { viewModel.abortChat() },
|
onAbort = { viewModel.abortChat() },
|
||||||
|
onToggleTalk = { viewModel.setTalkEnabled(!talkEnabled) },
|
||||||
onSend = { text ->
|
onSend = { text ->
|
||||||
val outgoing =
|
val outgoing =
|
||||||
attachments.map { att ->
|
attachments.map { att ->
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user