Выделение текста + фикс ANR при записи ГС

This commit is contained in:
2026-04-12 23:05:55 +05:00
parent ad08af7f0c
commit b1fc623f5e
5 changed files with 90 additions and 37 deletions

View File

@@ -30,7 +30,6 @@ data class Message(
val replyToMessageId: String? = null
)
/** UI модель диалога */
data class Dialog(
val opponentKey: String,
val opponentTitle: String,

View File

@@ -615,6 +615,8 @@ fun ChatDetailScreen(
showImageViewer,
showMediaPicker,
showEmojiPicker,
textSelectionHelper.isActive,
textSelectionHelper.movingHandle,
pendingCameraPhotoUri,
pendingGalleryImages,
showInAppCamera,
@@ -624,6 +626,8 @@ fun ChatDetailScreen(
showImageViewer ||
showMediaPicker ||
showEmojiPicker ||
textSelectionHelper.isActive ||
textSelectionHelper.movingHandle ||
pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() ||
showInAppCamera ||

View File

@@ -480,8 +480,9 @@ fun MessageBubble(
Box(
modifier =
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
Modifier.fillMaxWidth().pointerInput(isSystemSafeChat, textSelectionHelper?.isActive) {
if (isSystemSafeChat) return@pointerInput
if (textSelectionHelper?.isActive == true) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со
// скроллом

View File

@@ -85,6 +85,9 @@ class TextSelectionHelper {
var endHandleX by mutableFloatStateOf(0f)
var endHandleY by mutableFloatStateOf(0f)
// Back gesture callback — registered/unregistered by overlay
var backCallback: Any? = null
// Overlay position in window — set by TextSelectionOverlay
var overlayWindowX = 0f
var overlayWindowY = 0f
@@ -330,17 +333,18 @@ private fun FloatingToolbarPopup(
val info = helper.layoutInfo ?: return
val layout = info.layout
val density = LocalDensity.current
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
// Toolbar position: centered above selection, in overlay-local coords
// startHandleX/endHandleX are already overlay-local
// Toolbar positioned ABOVE selection top, in overlay-local coordinates
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
val selectionTopY = layout.getLineTop(startLine).toFloat() +
(info.windowY - helper.overlayWindowY)
val toolbarHeight = 40f // approximate toolbar height in px
val toolbarWidth = 160f // approximate toolbar width in px
val toolbarX = (selectionCenterX - toolbarWidth / 2f).coerceAtLeast(8f)
val toolbarY = (selectionTopY - toolbarHeight - 12f).coerceAtLeast(0f)
// Toolbar is ~48dp tall + 8dp gap above selection
val toolbarOffsetPx = with(density) { 56.dp.toPx() }
val toolbarWidthPx = with(density) { 200.dp.toPx() }
val toolbarX = (selectionCenterX - toolbarWidthPx / 2f).coerceAtLeast(with(density) { 8.dp.toPx() })
val toolbarY = (selectionTopY - toolbarOffsetPx).coerceAtLeast(0f)
Popup(
alignment = Alignment.TopStart,
@@ -395,6 +399,36 @@ fun TextSelectionOverlay(
val handleInsetPx = with(density) { HandleInset.toPx() }
val highlightCornerPx = with(density) { HighlightCorner.toPx() }
// Block predictive back gesture completely during text selection.
// BackHandler alone doesn't prevent the swipe animation on Android 13+
// with enableOnBackInvokedCallback=true. We must register an
// OnBackInvokedCallback at PRIORITY_OVERLAY to fully suppress it.
val activity = LocalContext.current as? android.app.Activity
LaunchedEffect(helper.isActive) {
if (android.os.Build.VERSION.SDK_INT >= 33 && activity != null) {
if (helper.isActive) {
val cb = android.window.OnBackInvokedCallback { /* consumed, do nothing */ }
activity.onBackInvokedDispatcher.registerOnBackInvokedCallback(
android.window.OnBackInvokedDispatcher.PRIORITY_OVERLAY, cb
)
helper.backCallback = cb
} else {
helper.backCallback?.let { cb ->
runCatching {
activity.onBackInvokedDispatcher.unregisterOnBackInvokedCallback(
cb as android.window.OnBackInvokedCallback
)
}
}
helper.backCallback = null
}
}
}
// Fallback for Android < 13
androidx.activity.compose.BackHandler(enabled = helper.isActive) {
// consumed — no navigation back while selecting
}
Box(
modifier = modifier
.fillMaxSize()
@@ -477,18 +511,22 @@ fun TextSelectionOverlay(
val startLine = layout.getLineForOffset(startOffset)
val endLine = layout.getLineForOffset(endOffset)
// Padding around highlight for breathing room
val padH = 3.dp.toPx()
val padV = 2.dp.toPx()
for (line in startLine..endLine) {
val lineTop = layout.getLineTop(line).toFloat() + offsetY
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY
val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV
val left = if (line == startLine) {
layout.getPrimaryHorizontal(startOffset) + offsetX
layout.getPrimaryHorizontal(startOffset) + offsetX - padH
} else {
layout.getLineLeft(line) + offsetX
layout.getLineLeft(line) + offsetX - padH
}
val right = if (line == endLine) {
layout.getPrimaryHorizontal(endOffset) + offsetX
layout.getPrimaryHorizontal(endOffset) + offsetX + padH
} else {
layout.getLineRight(line) + offsetX
layout.getLineRight(line) + offsetX + padH
}
drawRoundRect(
color = HighlightColor,

View File

@@ -1147,7 +1147,11 @@ fun MessageInputBar(
voiceRecordStartedAtMs = 0L
voiceElapsedMs = 0L
voiceWaves = emptyList()
resetGestureState()
setRecordUiState(RecordUiState.IDLE, "stop(send=$send)")
// Heavy I/O off main thread to prevent ANR
scope.launch(kotlinx.coroutines.Dispatchers.IO) {
var recordedOk = false
if (recorder != null) {
recordedOk = runCatching {
@@ -1162,6 +1166,7 @@ fun MessageInputBar(
val voiceHex =
runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("")
if (voiceHex.isNotBlank()) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
onSendVoiceMessage(
voiceHex,
durationSnapshot,
@@ -1169,9 +1174,10 @@ fun MessageInputBar(
)
}
}
}
runCatching { outputFile?.delete() }
resetGestureState()
setRecordUiState(RecordUiState.IDLE, "stop(send=$send)")
}
inputJumpLog(
"stopVoiceRecording end send=$send mode=$recordMode state=$recordUiState voice=$isVoiceRecording kb=$isKeyboardVisible " +
"emojiBox=${coordinator.isEmojiBoxVisible} panelH=$inputPanelHeightPx " +
@@ -1203,8 +1209,13 @@ fun MessageInputBar(
recorder.setAudioEncodingBitRate(32_000)
recorder.setAudioSamplingRate(48_000)
recorder.setOutputFile(output.absolutePath)
recorder.setMaxDuration(15 * 60 * 1000) // 15 min safety limit
recorder.prepare()
recorder.start()
recorder.setOnErrorListener { _, what, extra ->
inputJumpLog("MediaRecorder error what=$what extra=$extra")
stopVoiceRecording(send = false)
}
voiceRecorder = recorder
voiceOutputFile = output