feat(#1247): slide-up lock + release/network fixes for voice notes (#1280)

This commit is contained in:
Nikhil Nair authored and GitHub committed 2026-09-26 18:48:49 +03:00
1 parent 0bc0117c89
commit 2084f9083d
10 files changed
+1108 -355

No files matched your search

@@ -95,9 +95,12 @@ object ApiClient {
* the shared 30s client, and a re-submitted POST would repeat expensive
* provider work after the request may already have reached the server
* (the repository also passes `retries = 0`). The returned service reuses
* the shared connection pool and the auth/profile interceptor stack, and
* applies [readTimeoutMs] to reads and writes with transport retries
* disabled (review, PR #1250).
* the shared connection pool and the auth/profile interceptor stack. It
* applies [readTimeoutMs] to reads and writes and keeps transport-level
* retries on: they re-dial and can resend depending on when a failure
* struck, so they do not guarantee the absence of duplicate provider
* work — the risk is small, not zero. Application-level retry stays off
* (review, PR #1250 / #1280).
*/
fun transcriptionService(readTimeoutMs: Long): HermesApiService {
val client =
@@ -109,7 +112,16 @@ object ApiClient {
.authenticator(TokenRefreshAuthenticator)
.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.writeTimeout(readTimeoutMs, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(false)
// Transport-level stale-socket rescue stays ON: the shared pool
// keeps connections far longer than the dashboard's keep-alive,
// so the first STT call after an idle spell can ride a socket
// the server just closed ("unexpected end of stream"). OkHttp
// re-dials and can resend depending on when the failure struck,
// and it may retry across routes — duplicate provider work is
// possible, though rare. Timeouts and 5xx are never replayed
// (safeApiCall still runs with retries = 0). Risk small, not
// zero (review, PR #1280).
.retryOnConnectionFailure(true)
.build()
return Retrofit
.Builder()
@@ -1,13 +1,17 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.delay
import java.io.File
import java.io.IOException
import java.net.ConnectException
import java.net.UnknownHostException
import java.util.Base64
/**
@@ -33,6 +37,10 @@ const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
* - The STT POST runs on a dedicated long-timeout client with `retries = 0`:
* re-submitting after a timeout or 5xx would repeat expensive provider
* work although the request may already have reached the server.
* - Connect-never-established failures (unreachable route, DNS blip — the
* usual suspects across Wi-Fi/cellular switches) get ONE delayed
* re-attempt: that request never reached the server, so no provider work
* is duplicated (device follow-up, #1247).
*
* Open for test substitution (mirrors [ChatPersistenceRepository]'s pattern).
*/
@@ -54,16 +62,23 @@ open class VoiceNoteRepository(
)
}
val dataUrl = "data:$VOICE_NOTE_MIME_TYPE;base64," + Base64.getEncoder().encodeToString(encoded)
val result =
safeApiCall(retries = 0) {
serviceProvider(transcriptionTimeoutMs(dataUrl)).transcribeAudio(
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
)
val service = serviceProvider(transcriptionTimeoutMs(dataUrl))
val result = requestTranscription(service, dataUrl)
// One bounded re-attempt, and only for connection-never-established
// failures — the one class where the request provably never reached
// the server, so no provider work is duplicated (device follow-up,
// #1247).
val finalResult =
if (result is NetworkResult.Failure && isConnectNeverEstablished(result.error)) {
delay(CONNECT_RETRY_DELAY_MS)
requestTranscription(service, dataUrl)
} else {
result
}
return when (result) {
return when (finalResult) {
is NetworkResult.Success -> {
NetworkResult.Success(
result.data
finalResult.data
?.transcript
.orEmpty()
.trim(),
@@ -71,11 +86,26 @@ open class VoiceNoteRepository(
}
is NetworkResult.Failure -> {
result
finalResult
}
}
}
private suspend fun requestTranscription(
service: HermesApiService,
dataUrl: String,
): NetworkResult<AudioTranscriptionResponse> =
safeApiCall(retries = 0) {
service.transcribeAudio(
AudioTranscriptionRequest(dataUrl = dataUrl, mimeType = VOICE_NOTE_MIME_TYPE),
)
}
private fun isConnectNeverEstablished(error: NetworkError): Boolean {
val cause = (error as? NetworkError.Connection)?.cause ?: return false
return cause is ConnectException || cause is UnknownHostException
}
companion object {
/** Floor for STT requests — desktop parity (`AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS`). */
const val MIN_REQUEST_TIMEOUT_MS = 180_000L
@@ -85,6 +115,9 @@ open class VoiceNoteRepository(
private const val BASE64_CHARS_PER_TIMEOUT_MS = 10L
/** One re-attempt window for connection-never-established failures. */
private const val CONNECT_RETRY_DELAY_MS = 750L
/**
* Timeout for the blocking STT endpoint, mirroring the desktop's
* `audioTranscribeRequestTimeoutMs`: ~0.1ms of budget per base64 char
@@ -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
@@ -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 {