This commit is contained in:
1 parent
0bc0117c89
commit
2084f9083d
10 files changed
+1108
-355
No files matched your search
@@ -908,9 +908,10 @@ fun ChatScreen(
|
||||
onMicHoldStart = mediaLaunchers.onMicHoldStart,
|
||||
onMicHoldEnd = mediaLaunchers.onMicHoldEnd,
|
||||
onMicHoldCancel = mediaLaunchers.onMicHoldCancel,
|
||||
onMicLock = mediaLaunchers.onMicLock,
|
||||
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
|
||||
isRecordingVoice = mediaLaunchers.isRecordingVoice,
|
||||
voiceNoteAmplitude = mediaLaunchers.voiceNoteAmplitude,
|
||||
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
|
||||
onStopGeneration = { viewModel.interruptSession() },
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isMainTurnBusy = state.isMainTurnBusy,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -16,11 +18,14 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -30,6 +35,7 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -47,9 +53,14 @@ 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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -113,8 +124,9 @@ fun ChatInputBar(
|
||||
onMicHoldStart: () -> Unit = {},
|
||||
onMicHoldEnd: () -> Unit = {},
|
||||
onMicHoldCancel: () -> Unit = {},
|
||||
onMicLock: () -> Unit = {},
|
||||
isVoiceNoteLocked: Boolean = false,
|
||||
isRecordingVoice: Boolean = false,
|
||||
voiceNoteAmplitude: State<Float> = remember { mutableStateOf(0f) },
|
||||
onStopGeneration: () -> Unit = {},
|
||||
) {
|
||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||
@@ -130,260 +142,408 @@ 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).
|
||||
val inputFocusRequester = remember { FocusRequester() }
|
||||
val softwareKeyboard = LocalSoftwareKeyboardController.current
|
||||
var inputWasFocused 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.
|
||||
val handleMicHoldStart = {
|
||||
restoreInputFocus = inputWasFocused
|
||||
onMicHoldStart()
|
||||
}
|
||||
// A hold can arm and still fail to start the recorder (permission denied,
|
||||
// mic busy). Drop the focus snapshot then, so the end of some later
|
||||
// recording cannot pop the keyboard on its behalf (review, PR #1280).
|
||||
val handleMicHoldEnd = {
|
||||
if (!isRecordingVoice) restoreInputFocus = false
|
||||
onMicHoldEnd()
|
||||
}
|
||||
val handleMicHoldCancel = {
|
||||
if (!isRecordingVoice) restoreInputFocus = false
|
||||
onMicHoldCancel()
|
||||
}
|
||||
LaunchedEffect(isRecordingVoice) {
|
||||
if (!isRecordingVoice && restoreInputFocus) {
|
||||
restoreInputFocus = false
|
||||
inputFocusRequester.requestFocus()
|
||||
softwareKeyboard?.show()
|
||||
}
|
||||
}
|
||||
// Telegram parity: the strip's hint slides and fades with the drag, so
|
||||
// the gesture reports its 1 → 0 cancel fraction here.
|
||||
val voiceSlideProgress = remember { mutableStateOf(1f) }
|
||||
// The "Slide up to lock recording" tooltip shows until the first
|
||||
// successful lock, then stays away (persisted, like Telegram).
|
||||
val context = LocalContext.current
|
||||
var voiceLockHintDone by remember {
|
||||
mutableStateOf(
|
||||
context
|
||||
.getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(VOICE_LOCK_HINT_KEY, false),
|
||||
)
|
||||
}
|
||||
LaunchedEffect(isVoiceNoteLocked) {
|
||||
if (isVoiceNoteLocked && !voiceLockHintDone) {
|
||||
voiceLockHintDone = true
|
||||
context
|
||||
.getSharedPreferences(VOICE_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(VOICE_LOCK_HINT_KEY, true)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
val palette = composerPalette()
|
||||
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
) {
|
||||
// One floating card holds the whole composer: suggestions, attachments,
|
||||
// the input and the controls row. Flat fill, hairline edge, no shadow.
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = palette.card,
|
||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||
Box {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||
// TUI-only commands that don't function on mobile (issue #574).
|
||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||
// also enforced at dispatch time (issue #576, deliverable #3).
|
||||
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
||||
val commandNames =
|
||||
(commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop"))
|
||||
.distinct()
|
||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||
// One floating card holds the whole composer: suggestions, attachments,
|
||||
// the input and the controls row. Flat fill, hairline edge, no shadow.
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = palette.card,
|
||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||
// TUI-only commands that don't function on mobile (issue #574).
|
||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||
// also enforced at dispatch time (issue #576, deliverable #3).
|
||||
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
||||
val commandNames =
|
||||
(
|
||||
commandCatalog.pairs.map { it[0] } +
|
||||
listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop")
|
||||
).distinct()
|
||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 200.dp),
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border =
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
),
|
||||
) {
|
||||
items(filteredCommands, key = { it }) { cmd ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
cmd,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 200.dp),
|
||||
) {
|
||||
items(filteredCommands, key = { it }) { cmd ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
cmd,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment preview chips
|
||||
AnimatedVisibility(
|
||||
visible = pendingAttachments.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
LazyRow(
|
||||
// Attachment preview chips
|
||||
AnimatedVisibility(
|
||||
visible = pendingAttachments.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
LazyRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
itemsIndexed(pendingAttachments) { index, attachment ->
|
||||
AttachmentChip(
|
||||
attachment = attachment,
|
||||
onPreview = { onPreviewAttachment(attachment) },
|
||||
onRemove = { onRemoveAttachment(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnected && !isSessionReady) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (sessionPreparationFailed) {
|
||||
R.string.chat_session_not_ready
|
||||
} else {
|
||||
R.string.chat_session_preparing
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = palette.placeholder,
|
||||
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── TOP ROW: Borderless input field ──
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
itemsIndexed(pendingAttachments) { index, attachment ->
|
||||
AttachmentChip(
|
||||
attachment = attachment,
|
||||
onPreview = { onPreviewAttachment(attachment) },
|
||||
onRemove = { onRemoveAttachment(index) },
|
||||
val placeholderText =
|
||||
when {
|
||||
!isConnected -> {
|
||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||
}
|
||||
|
||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||
stringResource(R.string.chat_input_placeholder_queue)
|
||||
}
|
||||
|
||||
isAgentTyping -> {
|
||||
stringResource(R.string.chat_input_placeholder_waiting)
|
||||
}
|
||||
|
||||
else -> {
|
||||
stringResource(R.string.chat_input_placeholder_type_message)
|
||||
}
|
||||
}
|
||||
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(
|
||||
inputFieldValue.text,
|
||||
fallback = ambientLayoutDirection,
|
||||
)
|
||||
}
|
||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||
|
||||
if (isRecordingVoice) {
|
||||
VoiceNoteRecordingPanel(
|
||||
slideProgress = voiceSlideProgress,
|
||||
locked = isVoiceNoteLocked,
|
||||
onCancel = onMicHoldCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnected && !isSessionReady) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (sessionPreparationFailed) {
|
||||
R.string.chat_session_not_ready
|
||||
} else {
|
||||
R.string.chat_session_preparing
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = palette.placeholder,
|
||||
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── TOP ROW: Borderless input field ──
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val placeholderText =
|
||||
when {
|
||||
!isConnected -> {
|
||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||
}
|
||||
|
||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||
stringResource(R.string.chat_input_placeholder_queue)
|
||||
}
|
||||
|
||||
isAgentTyping -> {
|
||||
stringResource(R.string.chat_input_placeholder_waiting)
|
||||
}
|
||||
|
||||
else -> {
|
||||
stringResource(R.string.chat_input_placeholder_type_message)
|
||||
}
|
||||
}
|
||||
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
|
||||
}
|
||||
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||
|
||||
if (isRecordingVoice) {
|
||||
VoiceNoteRecordingPanel(
|
||||
amplitude = voiceNoteAmplitude,
|
||||
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)
|
||||
.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,
|
||||
} 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
|
||||
},
|
||||
) {
|
||||
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(),
|
||||
)
|
||||
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()
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOTTOM ROW: Toolbar ──
|
||||
ComposerToolbar(
|
||||
isConnected = isConnected,
|
||||
currentSessionModel = currentSessionModel,
|
||||
reasoningLevel = reasoningLevel,
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = hasDraft,
|
||||
showQueue = showBusyActions,
|
||||
onSend = onSend,
|
||||
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
||||
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
||||
canInterrupt = canInterrupt,
|
||||
onStopGeneration = onStopGeneration,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
onReasoningSelected = onReasoningTap,
|
||||
onMicTap = onMicTap,
|
||||
onMicHoldStart = handleMicHoldStart,
|
||||
onMicHoldEnd = handleMicHoldEnd,
|
||||
onMicHoldCancel = handleMicHoldCancel,
|
||||
onMicLock = onMicLock,
|
||||
isVoiceNoteLocked = isVoiceNoteLocked,
|
||||
onSlideProgress = { voiceSlideProgress.value = it },
|
||||
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||
canDisableReasoning = canDisableReasoning,
|
||||
supportsReasoning = supportsReasoning,
|
||||
fastMode = fastMode,
|
||||
fastSupported = fastSupported,
|
||||
isFastModeChanging = isFastModeChanging,
|
||||
onToggleFastMode = onToggleFastMode,
|
||||
showModelProvider = showModelProvider,
|
||||
reasoningWireLevel = reasoningWireLevel,
|
||||
pendingReasoningLevel = pendingReasoningLevel,
|
||||
isSessionReady = isSessionReady,
|
||||
)
|
||||
|
||||
AttachmentTray(
|
||||
visible = showAttachmentTray,
|
||||
onDismissRequest = { showAttachmentTray = false },
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
onFileTap = onFileTap,
|
||||
)
|
||||
}
|
||||
|
||||
// ── BOTTOM ROW: Toolbar ──
|
||||
ComposerToolbar(
|
||||
isConnected = isConnected,
|
||||
currentSessionModel = currentSessionModel,
|
||||
reasoningLevel = reasoningLevel,
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = hasDraft,
|
||||
showQueue = showBusyActions,
|
||||
onSend = onSend,
|
||||
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
||||
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
||||
canInterrupt = canInterrupt,
|
||||
onStopGeneration = onStopGeneration,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
onReasoningSelected = onReasoningTap,
|
||||
onMicTap = onMicTap,
|
||||
onMicHoldStart = onMicHoldStart,
|
||||
onMicHoldEnd = onMicHoldEnd,
|
||||
onMicHoldCancel = onMicHoldCancel,
|
||||
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||
canDisableReasoning = canDisableReasoning,
|
||||
supportsReasoning = supportsReasoning,
|
||||
fastMode = fastMode,
|
||||
fastSupported = fastSupported,
|
||||
isFastModeChanging = isFastModeChanging,
|
||||
onToggleFastMode = onToggleFastMode,
|
||||
showModelProvider = showModelProvider,
|
||||
reasoningWireLevel = reasoningWireLevel,
|
||||
pendingReasoningLevel = pendingReasoningLevel,
|
||||
isSessionReady = isSessionReady,
|
||||
)
|
||||
|
||||
AttachmentTray(
|
||||
visible = showAttachmentTray,
|
||||
onDismissRequest = { showAttachmentTray = false },
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
onFileTap = onFileTap,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Zero-height anchor so the tooltip and lock icon float above the
|
||||
// card, over the message list, like Telegram's lock control. It sits
|
||||
// outside the animated card because AnimatedVisibility clips its
|
||||
// children to the animation bounds.
|
||||
Box(modifier = Modifier.fillMaxWidth().height(0.dp)) {
|
||||
VoiceNoteLockHintOverlay(
|
||||
visible = isRecordingVoice && !isVoiceNoteLocked,
|
||||
showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f,
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentHeight(align = Alignment.Top, unbounded = true)
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(y = (-42).dp)
|
||||
.padding(end = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram-style lock affordance floating over the composer while a voice
|
||||
* note records: the outlined lock icon with the "Slide up to lock recording"
|
||||
* tooltip beside it. The tooltip shows only until the first successful lock
|
||||
* (persisted in [VOICE_PREFS_NAME]); the icon rides along until the recording
|
||||
* locks hands-free.
|
||||
*/
|
||||
@Composable
|
||||
private fun VoiceNoteLockHintOverlay(
|
||||
visible: Boolean,
|
||||
showTextHint: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
val palette = composerPalette()
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showTextHint) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_voice_lock_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = palette.text,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(palette.card, RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.background(palette.control, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = palette.placeholder,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared prefs file for the voice-note UI. */
|
||||
private const val VOICE_PREFS_NAME = "chat_voice_prefs"
|
||||
|
||||
/** Set after the first successful hands-free lock, silencing the hint. */
|
||||
private const val VOICE_LOCK_HINT_KEY = "voice_note_lock_hint_done"
|
||||
|
||||
/**
|
||||
* Reusable attachment chip composable for showing a pending attachment
|
||||
* with a remove button.
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -40,7 +39,7 @@ import java.util.Locale
|
||||
class ChatMediaLaunchers(
|
||||
val isListening: Boolean,
|
||||
val isRecordingVoice: Boolean,
|
||||
val voiceNoteAmplitude: State<Float>,
|
||||
val isVoiceNoteLocked: Boolean,
|
||||
val onMicTap: () -> Unit,
|
||||
val onCameraTap: () -> Unit,
|
||||
val onImageTap: () -> Unit,
|
||||
@@ -48,6 +47,7 @@ class ChatMediaLaunchers(
|
||||
val onMicHoldStart: () -> Unit = {},
|
||||
val onMicHoldEnd: () -> Unit = {},
|
||||
val onMicHoldCancel: () -> Unit = {},
|
||||
val onMicLock: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -85,28 +85,14 @@ fun rememberChatMediaLaunchers(
|
||||
val voiceNoteRecorder = remember { VoiceNoteRecorder(context) }
|
||||
var isRecordingVoice by remember { mutableStateOf(false) }
|
||||
|
||||
// Live mic level for the recording panel — rises fast, decays slowly so
|
||||
// the meter reads as voice activity instead of flicker.
|
||||
val voiceNoteAmplitude = remember { mutableStateOf(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(90)
|
||||
val previous = voiceNoteAmplitude.value
|
||||
val next =
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
maxOf(voiceNoteRecorder.currentAmplitude() / 32767f, previous * 0.8f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
if (next != previous) {
|
||||
voiceNoteAmplitude.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
// Locked = the recording continues after the finger lifts (slide up
|
||||
// during the hold); the action button then submits it.
|
||||
var isVoiceNoteLocked by remember { mutableStateOf(false) }
|
||||
|
||||
fun finishVoiceRecording() {
|
||||
val recordedFile = voiceNoteRecorder.stop()
|
||||
isRecordingVoice = false
|
||||
isVoiceNoteLocked = false
|
||||
if (recordedFile != null) {
|
||||
currentOnVoiceNoteRecorded(recordedFile)
|
||||
}
|
||||
@@ -264,6 +250,9 @@ fun rememberChatMediaLaunchers(
|
||||
if (isTranscribingVoiceNote) {
|
||||
// Single-flight: a server transcription owns the voice pipeline
|
||||
// until it lands (review, PR #1250).
|
||||
} else if (isVoiceNoteLocked) {
|
||||
// The locked recording's action button submits the note.
|
||||
finishVoiceRecording()
|
||||
} else if (isRecordingVoice) {
|
||||
// A tap while recording discards the in-flight voice note.
|
||||
voiceNoteRecorder.cancel()
|
||||
@@ -334,6 +323,15 @@ fun rememberChatMediaLaunchers(
|
||||
voiceNoteRecorder.cancel()
|
||||
}
|
||||
isRecordingVoice = false
|
||||
isVoiceNoteLocked = false
|
||||
}
|
||||
|
||||
// Locking keeps the recorder running once the finger lifts; only the
|
||||
// panel's delete action or the action button can end it then.
|
||||
val onMicLock: () -> Unit = {
|
||||
if (voiceNoteRecorder.isActive) {
|
||||
isVoiceNoteLocked = true
|
||||
}
|
||||
}
|
||||
|
||||
val onCameraTap: () -> Unit = {
|
||||
@@ -369,11 +367,11 @@ fun rememberChatMediaLaunchers(
|
||||
}
|
||||
}
|
||||
|
||||
return remember(isListening, isRecordingVoice, isTranscribingVoiceNote) {
|
||||
return remember(isListening, isRecordingVoice, isVoiceNoteLocked, isTranscribingVoiceNote) {
|
||||
ChatMediaLaunchers(
|
||||
isListening = isListening || isRecordingVoice,
|
||||
isRecordingVoice = isRecordingVoice,
|
||||
voiceNoteAmplitude = voiceNoteAmplitude,
|
||||
isVoiceNoteLocked = isVoiceNoteLocked,
|
||||
onMicTap = onMicTap,
|
||||
onCameraTap = onCameraTap,
|
||||
onImageTap = onImageTap,
|
||||
@@ -381,6 +379,7 @@ fun rememberChatMediaLaunchers(
|
||||
onMicHoldStart = onMicHoldStart,
|
||||
onMicHoldEnd = onMicHoldEnd,
|
||||
onMicHoldCancel = onMicHoldCancel,
|
||||
onMicLock = onMicLock,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,12 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
@@ -127,6 +131,9 @@ fun ComposerToolbar(
|
||||
onMicHoldStart: () -> Unit = {},
|
||||
onMicHoldEnd: () -> Unit = {},
|
||||
onMicHoldCancel: () -> Unit = {},
|
||||
onMicLock: () -> Unit = {},
|
||||
isVoiceNoteLocked: Boolean = false,
|
||||
onSlideProgress: (Float) -> Unit = {},
|
||||
onStopGeneration: () -> Unit = {},
|
||||
) {
|
||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||
@@ -134,36 +141,74 @@ fun ComposerToolbar(
|
||||
val reasoningDisabledForModel = supportsReasoning == false
|
||||
val canDisable = canDisableReasoning
|
||||
|
||||
// Telegram-style voice notes: hold to record, release to send, slide away
|
||||
// to cancel. Both mic controls run the same single pointer loop
|
||||
// (micHoldHandler), which owns the whole press until finger-up — so the
|
||||
// release that submits the note can never be lost between recompositions.
|
||||
// Telegram-style voice notes: hold to record, release to send, slide left
|
||||
// to cancel, slide up to lock hands-free. Both mic controls run the same
|
||||
// single pointer loop (micHoldHandler), which owns the whole press until
|
||||
// finger-up — so the release that submits the note can never be lost
|
||||
// between recompositions.
|
||||
val currentIsConnected = rememberUpdatedState(isConnected)
|
||||
val currentShowSend = rememberUpdatedState(showSend)
|
||||
val currentCanInterrupt = rememberUpdatedState(canInterrupt)
|
||||
val currentIsVoiceNoteLocked = rememberUpdatedState(isVoiceNoteLocked)
|
||||
val currentOnMicHoldStart = rememberUpdatedState(onMicHoldStart)
|
||||
val currentOnMicHoldEnd = rememberUpdatedState(onMicHoldEnd)
|
||||
val currentOnMicHoldCancel = rememberUpdatedState(onMicHoldCancel)
|
||||
val currentOnMicLock = rememberUpdatedState(onMicLock)
|
||||
val currentOnSlideProgress = rememberUpdatedState(onSlideProgress)
|
||||
// The mic button is under the thumb during the gesture, so the outcome —
|
||||
// lock or cancel — must be felt as well as seen.
|
||||
val haptic = LocalHapticFeedback.current
|
||||
// Telegram's slide-to-cancel span: a third of the composer width, capped
|
||||
// at [MAX_CANCEL_DISTANCE_DP]. The gesture modifier sits on the mic button
|
||||
// itself (only ~40 dp wide), so the span derives from the screen width.
|
||||
val cancelDistancePx =
|
||||
with(LocalDensity.current) {
|
||||
minOf(
|
||||
LocalConfiguration.current.screenWidthDp.dp
|
||||
.toPx() * 0.35f,
|
||||
MAX_CANCEL_DISTANCE_DP.dp.toPx(),
|
||||
)
|
||||
}
|
||||
val flatMicGesture =
|
||||
Modifier.pointerInput(Unit) {
|
||||
micHoldHandler(
|
||||
isEnabled = {
|
||||
currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value
|
||||
currentIsConnected.value && currentShowSend.value && !currentCanInterrupt.value &&
|
||||
!currentIsVoiceNoteLocked.value
|
||||
},
|
||||
onHoldStart = { currentOnMicHoldStart.value() },
|
||||
onHoldEnd = { currentOnMicHoldEnd.value() },
|
||||
onHoldCancel = { currentOnMicHoldCancel.value() },
|
||||
onHoldCancel = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicHoldCancel.value()
|
||||
},
|
||||
onHoldLock = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicLock.value()
|
||||
},
|
||||
onSlideProgress = { currentOnSlideProgress.value(it) },
|
||||
cancelDistance = cancelDistancePx,
|
||||
)
|
||||
}
|
||||
val actionMicGesture =
|
||||
Modifier.pointerInput(Unit) {
|
||||
micHoldHandler(
|
||||
isEnabled = {
|
||||
currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value
|
||||
currentIsConnected.value && !currentShowSend.value && !currentCanInterrupt.value &&
|
||||
!currentIsVoiceNoteLocked.value
|
||||
},
|
||||
onHoldStart = { currentOnMicHoldStart.value() },
|
||||
onHoldEnd = { currentOnMicHoldEnd.value() },
|
||||
onHoldCancel = { currentOnMicHoldCancel.value() },
|
||||
onHoldCancel = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicHoldCancel.value()
|
||||
},
|
||||
onHoldLock = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
currentOnMicLock.value()
|
||||
},
|
||||
onSlideProgress = { currentOnSlideProgress.value(it) },
|
||||
cancelDistance = cancelDistancePx,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -488,19 +533,30 @@ fun ComposerToolbar(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledIconButton(
|
||||
onClick = onMicTap,
|
||||
enabled = isConnected,
|
||||
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
|
||||
// Plain Box, not FilledIconButton: the hold gesture must sit
|
||||
// innermost in the pointer-modifier chain so it consumes the
|
||||
// release before the tap handler — impossible while the button
|
||||
// supplies its own clickable (review, PR #1280). Same
|
||||
// arrangement as the action button below.
|
||||
val micColors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ControlSize)
|
||||
.testTag(if (isListening) "mic_stop_button" else "mic_button")
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isConnected) micColors.containerColor else micColors.disabledContainerColor,
|
||||
).combinedClickable(
|
||||
enabled = isConnected,
|
||||
onClick = onMicTap,
|
||||
).testTag(if (isListening) "mic_stop_button" else "mic_button")
|
||||
.then(flatMicGesture),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
|
||||
contentDescription = if (isListening) "Stop listening" else "Mic",
|
||||
tint = if (isConnected) micColors.contentColor else micColors.disabledContentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -527,7 +583,17 @@ fun ComposerToolbar(
|
||||
|
||||
// Action button — send when a send is possible, mic / stop otherwise
|
||||
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
|
||||
val actionEnabled = if (showSend) canSend else isConnected
|
||||
// While a recording is locked this button finishes the voice note, so
|
||||
// it must stay enabled even when the draft alone would not send
|
||||
// (review, PR #1280).
|
||||
val actionEnabled =
|
||||
if (isVoiceNoteLocked) {
|
||||
true
|
||||
} else if (showSend) {
|
||||
canSend
|
||||
} else {
|
||||
isConnected
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -540,7 +606,7 @@ fun ComposerToolbar(
|
||||
palette.action
|
||||
},
|
||||
).semantics {
|
||||
if (showSend && showQueue && canSend) {
|
||||
if (showSend && showQueue && canSend && !isVoiceNoteLocked) {
|
||||
customActions =
|
||||
listOf(
|
||||
CustomAccessibilityAction(stopAndSendLabel) {
|
||||
@@ -553,15 +619,23 @@ fun ComposerToolbar(
|
||||
enabled = actionEnabled,
|
||||
onClick = {
|
||||
when {
|
||||
// A locked recording always finishes with this
|
||||
// button, draft or agent state regardless
|
||||
// (review, PR #1280).
|
||||
isVoiceNoteLocked -> onMicTap()
|
||||
|
||||
canInterrupt -> onStopGeneration()
|
||||
|
||||
showSend -> onSend()
|
||||
|
||||
else -> onMicTap()
|
||||
}
|
||||
},
|
||||
onLongClick = if (showSend && showQueue) onStopAndSend else null,
|
||||
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
|
||||
onLongClick = if (showSend && showQueue && !isVoiceNoteLocked) onStopAndSend else null,
|
||||
onLongClickLabel = if (showSend && showQueue && !isVoiceNoteLocked) stopAndSendLabel else null,
|
||||
).testTag(
|
||||
when {
|
||||
isVoiceNoteLocked -> "voice_note_send_button"
|
||||
canInterrupt -> "stop_button"
|
||||
showSend -> "send_button"
|
||||
isListening -> "mic_stop_button"
|
||||
@@ -573,6 +647,7 @@ fun ComposerToolbar(
|
||||
Crossfade(
|
||||
targetState =
|
||||
when {
|
||||
isVoiceNoteLocked -> ActionGlyph.SEND
|
||||
canInterrupt -> ActionGlyph.STOP
|
||||
showSend -> ActionGlyph.SEND
|
||||
isListening -> ActionGlyph.STOP
|
||||
@@ -584,7 +659,12 @@ fun ComposerToolbar(
|
||||
ActionGlyph.SEND -> {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send_desc),
|
||||
contentDescription =
|
||||
if (isVoiceNoteLocked) {
|
||||
stringResource(R.string.chat_voice_send_desc)
|
||||
} else {
|
||||
stringResource(R.string.chat_send_desc)
|
||||
},
|
||||
tint = palette.onAction,
|
||||
)
|
||||
}
|
||||
@@ -720,36 +800,53 @@ fun buildReasoningLabel(
|
||||
|
||||
/**
|
||||
* Telegram-style mic gesture: press and hold past [HOLD_TO_RECORD_THRESHOLD_MS]
|
||||
* records a voice note, release sends it, and sliding away (left or up) from
|
||||
* the button cancels instead of sending. Short taps use the control's normal
|
||||
* click handler for dictation, so touch and accessibility actions share one
|
||||
* callback path.
|
||||
* records a voice note, release sends it, dragging into the cancel zone
|
||||
* cancels, and sliding up past [MIC_SLIDE_LOCK_DP] locks the recording
|
||||
* hands-free. Short taps use the control's normal click handler for dictation,
|
||||
* so touch and accessibility actions share one callback path.
|
||||
*
|
||||
* Distances and outcomes mirror Telegram's recorder: the full cancel distance
|
||||
* is a third of the composer capped at [MAX_CANCEL_DISTANCE_DP]; releasing
|
||||
* below [CANCEL_RELEASE_ALPHA] of it cancels (dragging back out before release
|
||||
* un-cancels); dragging all the way cancels mid-gesture; [onSlideProgress]
|
||||
* reports the 1 → 0 drag fraction so the recording strip can track and fade
|
||||
* its hint; locking only arms while the gesture stays clear of the cancel zone
|
||||
* ([LOCK_MIN_ALPHA]).
|
||||
*
|
||||
* The whole down-to-up sequence lives in this one pointer loop, so the release
|
||||
* that submits can never be lost to a recomposition between press and
|
||||
* finger-up — an earlier interaction-source based approach could lose it and
|
||||
* leave the recorder running until some later tap "sent" the stale clip.
|
||||
*
|
||||
* Once the hold phase arms, every event is consumed: the shared click handler
|
||||
* otherwise also observes the stationary release that ends a hold (there is no
|
||||
* drag to cancel it), and re-dispatches the tap action — the system dictation
|
||||
* intent launched every time a voice note was sent (regression, #1247).
|
||||
*/
|
||||
private suspend fun PointerInputScope.micHoldHandler(
|
||||
isEnabled: () -> Boolean,
|
||||
onHoldStart: () -> Unit,
|
||||
onHoldEnd: () -> Unit,
|
||||
onHoldCancel: () -> Unit,
|
||||
onHoldLock: () -> Unit,
|
||||
onSlideProgress: (Float) -> Unit,
|
||||
cancelDistance: Float,
|
||||
) {
|
||||
val cancelSlop = MIC_SLIDE_CANCEL_DP.dp.toPx()
|
||||
val lockSlop = MIC_SLIDE_LOCK_DP.dp.toPx()
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
if (!isEnabled()) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
fun slidAway(position: Offset): Boolean {
|
||||
val dx = position.x - down.position.x
|
||||
val dy = position.y - down.position.y
|
||||
return dx < -cancelSlop || dy < -cancelSlop
|
||||
}
|
||||
fun slideAlpha(position: Offset): Float =
|
||||
(1f + (position.x - down.position.x) / cancelDistance).coerceIn(0f, 1f)
|
||||
|
||||
// Phase 1 — a finger-up before the threshold is a plain tap.
|
||||
fun slidUp(position: Offset): Boolean = position.y - down.position.y < -lockSlop
|
||||
|
||||
// Phase 1 — a finger-up before the threshold is a plain tap, left to
|
||||
// the click handler. Dragging into the cancel zone aborts the gesture.
|
||||
// Events are not consumed yet, so the tap path stays intact.
|
||||
var earlyUp = false
|
||||
var gestureAborted = false
|
||||
withTimeoutOrNull(HOLD_TO_RECORD_THRESHOLD_MS) {
|
||||
@@ -765,7 +862,7 @@ private suspend fun PointerInputScope.micHoldHandler(
|
||||
earlyUp = true
|
||||
return@withTimeoutOrNull
|
||||
}
|
||||
if (change.isConsumed || slidAway(change.position)) {
|
||||
if (change.isConsumed || slideAlpha(change.position) < CANCEL_RELEASE_ALPHA) {
|
||||
gestureAborted = true
|
||||
return@withTimeoutOrNull
|
||||
}
|
||||
@@ -779,23 +876,44 @@ private suspend fun PointerInputScope.micHoldHandler(
|
||||
}
|
||||
|
||||
// Phase 2 — the threshold passed with the finger down: record until
|
||||
// the finger lifts (send) or slides away (cancel). No timeout here.
|
||||
// the finger lifts (send, unless dragged into the cancel zone), drags
|
||||
// all the way left (cancel), or slides up (lock hands-free).
|
||||
onHoldStart()
|
||||
var send = false
|
||||
var lock = false
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
val alpha = slideAlpha(change.position)
|
||||
onSlideProgress(alpha)
|
||||
if (!change.pressed) {
|
||||
send = true
|
||||
change.consume()
|
||||
send = alpha >= CANCEL_RELEASE_ALPHA
|
||||
break
|
||||
}
|
||||
if (change.isConsumed || slidAway(change.position)) {
|
||||
if (change.isConsumed) {
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
if (alpha <= 0f) {
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
if (slidUp(change.position) && alpha >= LOCK_MIN_ALPHA) {
|
||||
lock = true
|
||||
change.consume()
|
||||
break
|
||||
}
|
||||
change.consume()
|
||||
}
|
||||
} finally {
|
||||
if (send) onHoldEnd() else onHoldCancel()
|
||||
onSlideProgress(1f)
|
||||
when {
|
||||
lock -> onHoldLock()
|
||||
send -> onHoldEnd()
|
||||
else -> onHoldCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -803,5 +921,14 @@ private suspend fun PointerInputScope.micHoldHandler(
|
||||
/** A press shorter than this is a tap; longer arms voice-note recording. */
|
||||
private const val HOLD_TO_RECORD_THRESHOLD_MS = 400L
|
||||
|
||||
/** Sliding this far from the mic button (left or up) cancels the recording. */
|
||||
private const val MIC_SLIDE_CANCEL_DP = 64
|
||||
/** Sliding this far up from the mic button locks the recording hands-free. */
|
||||
private const val MIC_SLIDE_LOCK_DP = 57
|
||||
|
||||
/** Full slide-to-cancel distance: a third of the composer (Telegram). */
|
||||
private const val MAX_CANCEL_DISTANCE_DP = 140
|
||||
|
||||
/** Releasing below this drag fraction of the cancel distance cancels. */
|
||||
private const val CANCEL_RELEASE_ALPHA = 0.45f
|
||||
|
||||
/** Locking only arms while the gesture stays clear of the cancel zone. */
|
||||
private const val LOCK_MIN_ALPHA = 0.7f
|
||||
+143
-56
@@ -1,51 +1,79 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Telegram-style recording strip shown in place of the input field while a
|
||||
* voice note is being recorded: a slide-to-cancel hint, an elapsed timer, and
|
||||
* a live mic level meter so the press and the captured audio are both visible
|
||||
* even when the mic button itself is under the thumb.
|
||||
* voice note is being recorded. Layout mirrors Telegram's recorder: a blinking
|
||||
* red dot and the elapsed timer sit on the left; "◀ Slide to cancel" (with the
|
||||
* word "cancel" emphasized) sits centered to their right, gently nudging back
|
||||
* and forth, and slides left and fades as the gesture drags toward cancel
|
||||
* ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to
|
||||
* lock, the hint is replaced by a bold tappable "CANCEL" and the action button
|
||||
* submits the note.
|
||||
*/
|
||||
@Composable
|
||||
internal fun VoiceNoteRecordingPanel(
|
||||
amplitude: State<Float>,
|
||||
slideProgress: State<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
locked: Boolean = false,
|
||||
onCancel: () -> Unit = {},
|
||||
) {
|
||||
val palette = composerPalette()
|
||||
val density = LocalDensity.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var elapsedSeconds by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(Unit) {
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
@@ -54,71 +82,130 @@ internal fun VoiceNoteRecordingPanel(
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
val levels = remember { mutableStateListOf<Float>() }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { amplitude.value }.collect { level ->
|
||||
levels.add(level)
|
||||
while (levels.size > VOICE_NOTE_BARS) {
|
||||
levels.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Telegram blinks the record dot on a 1200 ms cycle.
|
||||
val blink = rememberInfiniteTransition(label = "voice_dot")
|
||||
val dotAlpha by
|
||||
blink.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.35f,
|
||||
animationSpec = infiniteRepeatable(tween(600, easing = LinearEasing), RepeatMode.Reverse),
|
||||
label = "voice_dot_alpha",
|
||||
)
|
||||
// ...and nudges the cancel hint ±6 dp while the gesture is idle (Telegram
|
||||
// moves it 3 dp / 250 ms), so the slide affordance announces itself.
|
||||
val nudgeDp by
|
||||
blink.animateFloat(
|
||||
initialValue = -6f,
|
||||
targetValue = 6f,
|
||||
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing), RepeatMode.Reverse),
|
||||
label = "voice_hint_nudge",
|
||||
)
|
||||
val progress = slideProgress.value.coerceIn(0f, 1f)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.heightIn(min = 42.dp)
|
||||
.testTag("voice_note_recording_panel"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
// Decorative: the adjacent "Slide to cancel" text carries the
|
||||
// instruction, so the arrow must not repeat it (review, PR #1250).
|
||||
contentDescription = null,
|
||||
tint = palette.placeholder,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_voice_slide_to_cancel),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = palette.placeholder,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.background(MaterialTheme.colorScheme.error, CircleShape),
|
||||
)
|
||||
modifier = Modifier.size(28.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(12.dp)
|
||||
.background(MaterialTheme.colorScheme.error.copy(alpha = dotAlpha), CircleShape),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = formatElapsed(elapsedSeconds),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = palette.text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = palette.placeholder,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.height(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
BoxWithConstraints(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clipToBounds(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val filler = VOICE_NOTE_BARS - levels.size
|
||||
repeat(VOICE_NOTE_BARS) { index ->
|
||||
val level = if (index < filler) 0f else levels[index - filler]
|
||||
Box(
|
||||
val containerWidthPx = with(density) { maxWidth.toPx() }
|
||||
if (locked) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_voice_cancel).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = palette.text,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height((4f + 18f * level).dp)
|
||||
.background(palette.placeholder, RoundedCornerShape(2.dp)),
|
||||
.minimumInteractiveComponentSize()
|
||||
.clickable(role = Role.Button) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onCancel()
|
||||
}.testTag("voice_note_cancel_button"),
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset {
|
||||
val shift = -containerWidthPx * 0.25f * (1f - progress)
|
||||
val nudge = nudgeDp * density.density * progress
|
||||
IntOffset((shift + nudge).roundToInt(), 0)
|
||||
}.alpha(progress),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CancelChevron(tint = palette.placeholder)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = slideToCancelText(palette),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of level-meter bars in the recording panel. */
|
||||
private const val VOICE_NOTE_BARS = 14
|
||||
/** "Slide to cancel" with the cancel word carrying the visual emphasis. */
|
||||
@Composable
|
||||
private fun slideToCancelText(palette: ComposerPalette) =
|
||||
buildAnnotatedString {
|
||||
// Two resources so translators control both parts independently; the
|
||||
// space between them is added here (review, PR #1280).
|
||||
withStyle(SpanStyle(color = palette.placeholder)) {
|
||||
append(stringResource(R.string.chat_voice_slide_to_cancel_prefix))
|
||||
append(" ")
|
||||
}
|
||||
withStyle(SpanStyle(color = palette.text, fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.chat_voice_slide_to_cancel_action))
|
||||
}
|
||||
}
|
||||
|
||||
/** The small drawn left chevron that precedes the cancel hint. */
|
||||
@Composable
|
||||
private fun CancelChevron(tint: Color) {
|
||||
Canvas(modifier = Modifier.size(width = 4.dp, height = 10.dp)) {
|
||||
val stroke = 1.6.dp.toPx()
|
||||
val midY = size.height / 2f
|
||||
val path =
|
||||
Path().apply {
|
||||
moveTo(size.width - stroke / 2f, midY - 5.dp.toPx())
|
||||
lineTo(stroke / 2f, midY)
|
||||
lineTo(size.width - stroke / 2f, midY + 5.dp.toPx())
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = tint,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats elapsed recording time as m:ss. */
|
||||
private fun formatElapsed(seconds: Long): String {
|
||||
|
||||
Reference in new issue
Block a user