feat: single-row input, pills inside card, new-chat button
ChatInput redesigned: single row (Plus | Text | Tune | Mic | Send). Pills hidden by default, expandable via tune icon with slide animation inside the same GlassSurface card. Shape lg (28dp) instead of pill. ModePillsRow moved from separate floating row into ChatInput as pillsContent lambda. Bottom dock scrim simplified. New Chat button (PenLine icon) added to top bar next to TokenBadge. LazyColumn bottom padding 260→140dp (dock is now smaller).
This commit is contained in:
parent
ad98b9f499
commit
4958ca8429
2 changed files with 113 additions and 63 deletions
|
|
@ -737,6 +737,7 @@ fun ChatInput(
|
||||||
pendingFiles: List<PendingFile> = emptyList(),
|
pendingFiles: List<PendingFile> = emptyList(),
|
||||||
onAddClick: () -> Unit = {},
|
onAddClick: () -> Unit = {},
|
||||||
onRemoveFile: (String) -> Unit = {},
|
onRemoveFile: (String) -> Unit = {},
|
||||||
|
pillsContent: @Composable (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
|
|
@ -746,76 +747,112 @@ fun ChatInput(
|
||||||
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
|
val hasContent = value.isNotBlank() || pendingFiles.any { it.uploaded != null }
|
||||||
val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
|
val canSend = enabled && hasContent && pendingFiles.none { it.uploading }
|
||||||
|
|
||||||
val iconBg = if (isDark) Color.White.copy(alpha = 0.07f) else Color.Black.copy(alpha = 0.05f)
|
var showPills by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
GlassSurface(
|
GlassSurface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
shape = KaizenShapes.pill,
|
shape = KaizenShapes.lg,
|
||||||
tintAlpha = if (isDark) 0.82f else 0.88f,
|
tintAlpha = if (isDark) 0.84f else 0.90f,
|
||||||
shadowStack = KaizenShadows.level3,
|
shadowStack = KaizenShadows.level3,
|
||||||
cornerRadius = 32.dp,
|
cornerRadius = 28.dp,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) {
|
Column(Modifier.padding(vertical = 10.dp)) {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
// Recording state — live waveform replaces the text field
|
Column(Modifier.padding(horizontal = 14.dp)) {
|
||||||
VoiceWaveform(amplitude = recordingAmplitude)
|
VoiceWaveform(amplitude = recordingAmplitude)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Box(
|
Box(
|
||||||
Modifier.size(42.dp).clip(KaizenShapes.circle)
|
Modifier.size(42.dp).clip(KaizenShapes.circle)
|
||||||
.background(KaizenSemanticColors.error)
|
.background(KaizenSemanticColors.error)
|
||||||
.clickable(onClick = onMicClick),
|
.clickable(onClick = onMicClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp))
|
Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Expandable pills row
|
||||||
|
if (pillsContent != null) {
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = showPills,
|
||||||
|
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
|
||||||
|
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
pillsContent()
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 14.dp).height(0.5.dp)
|
||||||
|
.background(cs.onSurfaceVariant.copy(alpha = 0.10f))
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingFiles.isNotEmpty()) {
|
if (pendingFiles.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 10.dp),
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 14.dp, end = 14.dp, bottom = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
|
pendingFiles.forEach { pf -> PendingFileChip(pf, onRemove = { onRemoveFile(pf.id) }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth().heightIn(min = 48.dp), contentAlignment = Alignment.CenterStart) {
|
// Single-row input: Plus | TextField | Mic | Send/Call
|
||||||
if (value.isEmpty()) Text(
|
Row(
|
||||||
stringResource(R.string.chat_input_placeholder),
|
Modifier.fillMaxWidth().padding(horizontal = 10.dp),
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
verticalAlignment = Alignment.Bottom,
|
||||||
fontSize = 16.sp,
|
) {
|
||||||
fontWeight = FontWeight.W300,
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = value, onValueChange = onValueChange,
|
|
||||||
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W300),
|
|
||||||
cursorBrush = SolidColor(accent.primary), maxLines = 6, modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.size(42.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onAddClick),
|
Modifier.size(38.dp).clip(KaizenShapes.circle).clickable(onClick = onAddClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
Icon(KaizenIcons.Plus, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(21.dp))
|
||||||
}
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Box(
|
|
||||||
Modifier.size(42.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onMicClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Box(
|
||||||
|
Modifier.weight(1f).heightIn(min = 38.dp).padding(horizontal = 4.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (value.isEmpty()) Text(
|
||||||
|
stringResource(R.string.chat_input_placeholder),
|
||||||
|
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.W300,
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = value, onValueChange = onValueChange,
|
||||||
|
textStyle = TextStyle(color = cs.onBackground, fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W300),
|
||||||
|
cursorBrush = SolidColor(accent.primary), maxLines = 4, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pillsContent != null) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(38.dp).clip(KaizenShapes.circle).clickable { showPills = !showPills },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
KaizenIcons.SlidersHorizontal, null,
|
||||||
|
tint = if (showPills) accent.primary else cs.onSurfaceVariant.copy(alpha = 0.55f),
|
||||||
|
modifier = Modifier.size(19.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier.size(38.dp).clip(KaizenShapes.circle).clickable(onClick = onMicClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(KaizenIcons.Mic, stringResource(R.string.chat_mic_permission), tint = cs.onSurfaceVariant, modifier = Modifier.size(21.dp))
|
||||||
|
}
|
||||||
|
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
StopButton(onClick = onStop)
|
StopButton(onClick = onStop)
|
||||||
|
|
|
||||||
|
|
@ -863,7 +863,7 @@ fun ChatScreen(
|
||||||
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
|
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = 100.dp,
|
top = 100.dp,
|
||||||
bottom = 260.dp,
|
bottom = 140.dp,
|
||||||
start = 16.dp,
|
start = 16.dp,
|
||||||
end = 16.dp
|
end = 16.dp
|
||||||
),
|
),
|
||||||
|
|
@ -1035,6 +1035,18 @@ fun ChatScreen(
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
|
val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
|
||||||
TokenBadge(used = usedTokens, contextLength = ctxLen)
|
TokenBadge(used = usedTokens, contextLength = ctxLen)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
Modifier.size(36.dp).clip(KaizenShapes.circle)
|
||||||
|
.clickable { haptics.tick(); newChat() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
KaizenIcons.PenLine, null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(17.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1089,22 +1101,7 @@ fun ChatScreen(
|
||||||
)
|
)
|
||||||
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
|
.then(if (isTablet) Modifier.widthIn(max = 720.dp) else Modifier)
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(if (imeVisible) 6.dp else 24.dp))
|
Spacer(Modifier.height(if (imeVisible) 4.dp else 12.dp))
|
||||||
ModePillsRow(
|
|
||||||
activeMode = activeMode,
|
|
||||||
onToggle = { mode ->
|
|
||||||
activeMode = if (activeMode == mode) null else mode
|
|
||||||
haptics.tick()
|
|
||||||
},
|
|
||||||
reasoningPreset = reasoningPreset,
|
|
||||||
onReasoningChange = { reasoningPreset = it; haptics.tick() },
|
|
||||||
samplingPrefs = samplingPrefs,
|
|
||||||
onSamplingChange = { samplingPrefs = it },
|
|
||||||
currentModelId = session.config?.model ?: "",
|
|
||||||
webSearchProvider = webSearchProvider,
|
|
||||||
onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() },
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(if (imeVisible) 6.dp else 10.dp))
|
|
||||||
ChatInput(
|
ChatInput(
|
||||||
value = input,
|
value = input,
|
||||||
onValueChange = { input = it },
|
onValueChange = { input = it },
|
||||||
|
|
@ -1118,6 +1115,22 @@ fun ChatScreen(
|
||||||
pendingFiles = pendingFiles,
|
pendingFiles = pendingFiles,
|
||||||
onAddClick = { showAttachMenu = !showAttachMenu },
|
onAddClick = { showAttachMenu = !showAttachMenu },
|
||||||
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
|
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
|
||||||
|
pillsContent = {
|
||||||
|
ModePillsRow(
|
||||||
|
activeMode = activeMode,
|
||||||
|
onToggle = { mode ->
|
||||||
|
activeMode = if (activeMode == mode) null else mode
|
||||||
|
haptics.tick()
|
||||||
|
},
|
||||||
|
reasoningPreset = reasoningPreset,
|
||||||
|
onReasoningChange = { reasoningPreset = it; haptics.tick() },
|
||||||
|
samplingPrefs = samplingPrefs,
|
||||||
|
onSamplingChange = { samplingPrefs = it },
|
||||||
|
currentModelId = session.config?.model ?: "",
|
||||||
|
webSearchProvider = webSearchProvider,
|
||||||
|
onWebSearchProviderChange = { webSearchProvider = it; haptics.tick() },
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp))
|
Spacer(Modifier.height(if (imeVisible) 4.dp else 16.dp))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue