1 parent
ee1d7a0b75
commit
57be85d2f7
2 files changed
+108
-74
No files matched your search
@@ -52,6 +52,7 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -137,20 +138,31 @@ fun ChatInputBar(
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
// The voice-note strip replaces the input field, so the IME closes the
|
||||
// moment recording starts. When the strip goes away (send, delete, or
|
||||
// cancel), focus returns to the input and the keyboard re-opens if it was
|
||||
// up before — the next message must not need an extra tap (device
|
||||
// follow-up, #1247).
|
||||
// The voice-note strip renders inside the input field's decoration box, so
|
||||
// the field stays composed and focused through the whole hold and the
|
||||
// keyboard no longer collapses when recording starts (device follow-up,
|
||||
// #1247). The restore below is only a safety net for a focus that still
|
||||
// got lost (IME quirk); when the field kept focus, its IME state is left
|
||||
// exactly as the user left it.
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
val softwareKeyboard = LocalSoftwareKeyboardController.current
|
||||
var inputWasFocused by remember { mutableStateOf(false) }
|
||||
var inputFocused by remember { mutableStateOf(false) }
|
||||
var restoreInputFocus by remember { mutableStateOf(false) }
|
||||
// Snapshot BEFORE the launcher flips isRecordingVoice — by the time the
|
||||
// recomposition swaps the field out, focus is already on its way to the
|
||||
// root, so the effect alone cannot read it reliably.
|
||||
// While a voice note records, the composer's trailing slots must not
|
||||
// morph: the send/queue AnimatedVisibility content would be disposed
|
||||
// under the holding finger, dropping the pointer loop (and with it the
|
||||
// release). The gesture loop already ignores these slots, so hold-start
|
||||
// freezes the slot layout synchronously (an effect would land a frame
|
||||
// late, after the slots already collapsed) and recording-end thaws them.
|
||||
val holdShowSend = remember { mutableStateOf(false) }
|
||||
val holdCanInterrupt = remember { mutableStateOf(false) }
|
||||
// Snapshot at hold start: whether the keyboard was up (for the restore
|
||||
// safety net) and the trailing slot layout (frozen while recording, see
|
||||
// above).
|
||||
val handleMicHoldStart = {
|
||||
restoreInputFocus = inputWasFocused
|
||||
restoreInputFocus = inputFocused
|
||||
holdShowSend.value = hasDraft
|
||||
holdCanInterrupt.value = canInterrupt
|
||||
onMicHoldStart()
|
||||
}
|
||||
// A hold can arm and still fail to start the recorder (permission denied,
|
||||
@@ -194,6 +206,10 @@ fun ChatInputBar(
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
val recording = isRecordingVoice
|
||||
val composerShowSend = if (recording) holdShowSend.value else hasDraft
|
||||
val composerCanInterrupt = if (recording) holdCanInterrupt.value else canInterrupt
|
||||
|
||||
val palette = composerPalette()
|
||||
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
|
||||
|
||||
@@ -347,70 +363,84 @@ fun ChatInputBar(
|
||||
}
|
||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||
|
||||
if (isRecordingVoice) {
|
||||
VoiceNoteRecordingPanel(
|
||||
slideProgress = voiceSlideProgress,
|
||||
locked = isVoiceNoteLocked,
|
||||
onCancel = onMicHoldCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = onInputChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 42.dp, max = 200.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(inputFocusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (!isRecordingVoice) {
|
||||
inputWasFocused = focusState.isFocused
|
||||
}
|
||||
}.testTag("chat_input"),
|
||||
enabled = isConnected,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
if (isInputRtl) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign =
|
||||
if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
// The field stays composed while a voice note records —
|
||||
// drawn transparent, with the strip overlaid on top — so
|
||||
// focus and the keyboard survive the hold (device
|
||||
// follow-up, #1247). The overlay is a sibling, not the
|
||||
// field's decoration content: decorationBox content does
|
||||
// not size the field, so the strip would be clipped to
|
||||
// the empty field's line height and vanish (CI, PR
|
||||
// #1351). Keystrokes mid-hold are swallowed, and the
|
||||
// composer's send/stop slots stay frozen — flipping them
|
||||
// under the finger would drop the hold.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 42.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (isRecordingVoice) 0f else 1f)
|
||||
.heightIn(min = 42.dp, max = 200.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(inputFocusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
inputFocused = focusState.isFocused
|
||||
}.testTag("chat_input"),
|
||||
enabled = isConnected,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
if (isInputRtl) {
|
||||
Alignment.CenterEnd
|
||||
} else {
|
||||
Alignment.CenterStart
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign =
|
||||
if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
if (isRecordingVoice) {
|
||||
// On top of the transparent field: keeps the
|
||||
// strip's real 42 dp+ geometry instead of being
|
||||
// clipped to the empty field's line height.
|
||||
VoiceNoteRecordingPanel(
|
||||
slideProgress = voiceSlideProgress,
|
||||
locked = isVoiceNoteLocked,
|
||||
onCancel = onMicHoldCancel,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -423,9 +453,9 @@ fun ChatInputBar(
|
||||
reasoningLevel = reasoningLevel,
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = hasDraft,
|
||||
showSend = composerShowSend,
|
||||
onSend = onSend,
|
||||
canInterrupt = canInterrupt,
|
||||
canInterrupt = composerCanInterrupt,
|
||||
onStopGeneration = onStopGeneration,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
|
||||
Reference in new issue
Block a user