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