From 56f52218edb8367dcc0f4493af04158579c5d5b9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:20:30 +0200 Subject: [PATCH] voice: watch-quality recording UX - slide-to-cancel with magnetic X target (leans toward finger, blushes red with proximity, snaps on hover, REJECT haptic on cancel, firm click on enter/exit), release-anywhere tracking replaces button-owned release, timestamp left of waveform showing elapsed only, muted 1.5dp red outline, neutral grey recording pill with depth --- .../com/bitchat/android/ui/InputComponents.kt | 188 ++++++++++++++-- .../android/ui/VoiceInputComponents.kt | 200 +++++++++++------- 2 files changed, 294 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 7302379b..f3d3e5fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import android.view.HapticFeedbackConstants import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher @@ -13,7 +14,9 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally @@ -43,6 +46,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color @@ -57,7 +65,10 @@ import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import kotlin.math.roundToInt import androidx.compose.ui.unit.sp import com.bitchat.android.R import androidx.compose.ui.focus.onFocusChanged @@ -317,21 +328,68 @@ fun MessageInput( var elapsedMs by remember { mutableStateOf(0L) } var amplitude by remember { mutableStateOf(0) } - // Recording is the one state worth shouting about, so it overrides focus. + // Slide-to-cancel: while recording, the mic button streams the finger position (root + // coords) up here; the cancel disc beside it reports its bounds. Approaching the disc + // makes it lean toward the finger and blush red; only entering it activates cancel. + var cancelBounds by remember { mutableStateOf(null) } + var cancelFinger by remember { mutableStateOf(null) } + val density = LocalDensity.current + val cancelSlackPx = with(density) { 8.dp.toPx() } + val cancelHover = cancelFinger != null && + cancelBounds?.inflate(cancelSlackPx)?.contains(cancelFinger!!) == true + val cancelCenter = cancelBounds?.center + val cancelProximity: Float + val cancelPull: Offset + val trackedFinger = cancelFinger + if (trackedFinger != null && cancelCenter != null) { + val toFinger = trackedFinger - cancelCenter + val dist = toFinger.getDistance() + val outer = with(density) { 36.dp.toPx() } + val inner = with(density) { 18.dp.toPx() } + cancelProximity = ((outer - dist) / (outer - inner)).coerceIn(0f, 1f) + cancelPull = if (dist > 1f) { + toFinger * (cancelProximity * with(density) { 12.dp.toPx() } / dist) + } else Offset.Zero + } else { + cancelProximity = 0f + cancelPull = Offset.Zero + } + // A firm, physical click each time the finger enters or leaves the cancel target. + val view = LocalView.current + var cancelHoverHapticState by remember { mutableStateOf(false) } + LaunchedEffect(cancelHover, isRecording) { + if (!isRecording) { + cancelHoverHapticState = false + } else if (cancelHover != cancelHoverHapticState) { + view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + cancelHoverHapticState = cancelHover + } + } + + // Recording is the one state worth shouting about, so it overrides focus. While recording + // the outline also firms up slightly in the same fast sweep — present, but muted. val borderColor by animateColorAsState( targetValue = when { - isRecording -> colorScheme.error.copy(alpha = 0.7f) + isRecording -> colorScheme.error.copy(alpha = 0.65f) isFocused.value -> palette.inputOutlineFocused else -> palette.inputOutline }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerBorder" ) - // A barely-there lift on focus. Enough to register, not enough to look like a different - // component. Slightly translucent so the messages scrolling underneath stay faintly visible. + val borderWidth by animateDpAsState( + targetValue = if (isRecording) 1.5.dp else 1.dp, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerBorderWidth" + ) + // A barely-there lift on focus. While recording the pill turns into a neutral grey slab + // (NOT the brand-tinted elevation color) so it protrudes from the flat black chat. val containerColor by animateColorAsState( - targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) - .copy(alpha = ComposerFillAlpha), + targetValue = when { + isRecording -> colorScheme.surfaceVariant.copy(alpha = 0.97f) + else -> (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha) + }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerContainer" ) @@ -351,7 +409,7 @@ fun MessageInput( animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) ) .background(containerColor, ComposerShape) - .border(1.dp, borderColor, ComposerShape), + .border(borderWidth, borderColor, ComposerShape), verticalAlignment = Alignment.Bottom ) { Box( @@ -439,22 +497,21 @@ fun MessageInput( .alpha(waveformAlpha), verticalAlignment = Alignment.CenterVertically ) { - RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(22.dp), - amplitudeNorm = normalizeAmplitudeSample(amplitude) - ) - Spacer(Modifier.width(12.dp)) + // Timestamp on the left, clear of the thumb resting on the record + // button; the waveform keeps the remaining width and its history + // scrolls off the left edge while live data streams in from the right. val secs = (elapsedMs / 1000).toInt() - val maxSecs = 10 // 10 second max recording time Text( - text = String.format( - "%02d:%02d / %02d:%02d", - secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 - ), + text = String.format("%02d:%02d", secs / 60, secs % 60), fontFamily = BitchatFontFamily, color = colorScheme.error, fontSize = (BASE_FONT_SIZE - 4).sp ) + Spacer(Modifier.width(12.dp)) + RealtimeScrollingWaveform( + modifier = Modifier.weight(1f).height(22.dp), + amplitudeNorm = normalizeAmplitudeSample(amplitude) + ) } } } @@ -528,8 +585,35 @@ fun MessageInput( ) } + // The slide-to-cancel target sits well clear of the record + // button (camera's slot plus a gap), rests as a cancel disc, + // leans toward an approaching finger and snaps red on hover. + AnimatedVisibility( + visible = isRecording, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandHorizontally( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkHorizontally( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RecordingCancelButton( + hover = cancelHover, + proximity = cancelProximity, + pull = cancelPull, + onBounds = { cancelBounds = it } + ) + Spacer(Modifier.width(24.dp)) + } + } + VoiceRecordButton( isRecording = isRecording, + shouldCancel = { cancelHover }, + onTrackFinger = { cancelFinger = it }, onStart = { isRecording = true elapsedMs = 0L @@ -583,6 +667,76 @@ fun MessageInput( // Auto-stop handled inside VoiceRecordButton } +/** + * Slide-to-cancel target shown beside the record button while capturing. It always shows the + * cancel glyph so the destination is unambiguous; as the finger approaches it leans toward + * it (magnetic pull) and blushes red, and on contact it blooms. Release there cancels; + * sliding back out returns to send mode. All motion is spring-driven so it stays fluid. + */ +@Composable +private fun RecordingCancelButton( + hover: Boolean, + proximity: Float, + pull: Offset, + onBounds: (Rect) -> Unit, + modifier: Modifier = Modifier +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + + val pullAnim by animateOffsetAsState( + targetValue = pull, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "cancelPull" + ) + val scale by animateFloatAsState( + targetValue = if (hover) 1.28f else 1f + 0.1f * proximity, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "cancelScale" + ) + val container = androidx.compose.ui.graphics.lerp( + palette.inputButton, + colorScheme.error, + if (hover) 1f else proximity * 0.85f + ) + val tint = androidx.compose.ui.graphics.lerp( + colorScheme.onSurfaceVariant, + colorScheme.onError, + if (hover) 1f else proximity * 0.6f + ) + + Box( + modifier = modifier + .onGloballyPositioned { coords -> + onBounds(Rect(coords.localToRoot(Offset.Zero), coords.size.toSize())) + } + .size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .offset { IntOffset(pullAnim.x.roundToInt(), pullAnim.y.roundToInt()) } + .background(container, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Cancel recording", + tint = tint, + modifier = Modifier.size(ComposerIconSize) + ) + } + } +} + /** * Send affordance. Only rendered when there is something to send, so its mere presence is the * signal; it does not need to shout in the terminal's full-brightness green as well. diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c9922bf1..31aadd60 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -1,18 +1,25 @@ package com.bitchat.android.ui +import android.Manifest +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic -import android.Manifest -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.res.stringResource import com.bitchat.android.features.voice.VoiceRecorder import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus @@ -22,6 +29,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import java.io.File /** * How long the button must be held before a recording starts. @@ -59,24 +67,35 @@ fun VoiceRecordButton( * pill's border change together instead of one lagging the other. */ isRecording: Boolean = false, + /** + * Consulted the instant the finger lifts: when it reports true (finger over the + * slide-to-cancel target), the recording is discarded instead of sent. + */ + shouldCancel: () -> Boolean = { false }, + /** + * Finger position in root coordinates while a capture is live (drives the magnetic + * cancel target); null once the gesture ends. + */ + onTrackFinger: (Offset?) -> Unit = {}, onStart: () -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, onFinish: (filePath: String) -> Unit, /** * Invoked whenever a recording ends without producing a file — permission denied, recorder - * failure, or the button being torn down mid-capture. The caller needs this to clear its own - * recording state; without it a failed capture left the composer stuck in recording mode. + * failure, the button being torn down mid-capture, or a deliberate slide-to-cancel. */ onCancel: () -> Unit = {} ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current + val view = LocalView.current val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) var isCapturing by remember { mutableStateOf(false) } var recorder by remember { mutableStateOf(null) } var recordedFilePath by remember { mutableStateOf(null) } var recordingStart by remember { mutableStateOf(0L) } + var buttonCoords by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() var ampJob by remember { mutableStateOf(null) } @@ -86,6 +105,8 @@ fun VoiceRecordButton( val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) val latestOnCancel = rememberUpdatedState(onCancel) + val latestShouldCancel = rememberUpdatedState(shouldCancel) + val latestOnTrackFinger = rememberUpdatedState(onTrackFinger) // Set when this instance was composed, so presses inherited from whatever occupied this spot // beforehand can be rejected. @@ -110,6 +131,7 @@ fun VoiceRecordButton( runCatching { recorder?.stop() } recorder = null recordedFilePath = null + latestOnTrackFinger.value(null) latestOnCancel.value() } } @@ -120,99 +142,123 @@ fun VoiceRecordButton( isActive = isRecording || isCapturing, isPressed = isCapturing, modifier = modifier + .onGloballyPositioned { buttonCoords = it } .pointerInput(Unit) { - detectTapGestures( - onPress = { - // Guard 1: ignore anything arriving before the swap animation settled. - if (System.currentTimeMillis() - composedAt < ArmDelayMs) { - return@detectTapGestures - } - // Guard 2: never start a second capture on top of a live one. - if (isCapturing) return@detectTapGestures + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + // Guard 1: ignore anything arriving before the swap animation settled. + if (System.currentTimeMillis() - composedAt < ArmDelayMs) { + return@awaitEachGesture + } + // Guard 2: never start a second capture on top of a live one. + if (isCapturing) return@awaitEachGesture - if (micPermission.status !is PermissionStatus.Granted) { - micPermission.launchPermissionRequest() - return@detectTapGestures - } + if (micPermission.status !is PermissionStatus.Granted) { + micPermission.launchPermissionRequest() + return@awaitEachGesture + } - // Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on - // release and false on cancellation; either way the press was not a hold, - // so nothing should happen. Only a timeout means the finger is still down. - val stillHeld = withTimeoutOrNull(HoldToRecordMs) { - tryAwaitRelease() - } == null - if (!stillHeld) return@detectTapGestures + // Guard 3: require a deliberate hold. An up (or a stolen pointer) inside the + // arm window means the press was never a hold; only the timeout means the + // finger is still down. + var stolenDuringArm = false + val releasedEarly = withTimeoutOrNull(HoldToRecordMs) { + waitForUpOrCancellation().also { if (it == null) stolenDuringArm = true } + } + if (releasedEarly != null || stolenDuringArm) return@awaitEachGesture - val rec = VoiceRecorder(context) - val startedFile = rec.start() - if (startedFile == null) { - // Recorder refused to start; make sure the caller does not sit in a - // recording state that never began. - runCatching { rec.stop() } - latestOnCancel.value() - return@detectTapGestures - } + val rec = VoiceRecorder(context) + val startedFile = rec.start() + if (startedFile == null) { + // Recorder refused to start; make sure the caller does not sit in a + // recording state that never began. + runCatching { rec.stop() } + latestOnCancel.value() + return@awaitEachGesture + } - recorder = rec - recordedFilePath = startedFile.absolutePath - recordingStart = System.currentTimeMillis() - isCapturing = true - latestOnStart.value() - buzz() + recorder = rec + recordedFilePath = startedFile.absolutePath + recordingStart = System.currentTimeMillis() + isCapturing = true + latestOnStart.value() + buzz() - ampJob?.cancel() - ampJob = scope.launch { - while (isActive && isCapturing) { - val amp = recorder?.pollAmplitude() ?: 0 - val elapsed = - (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) - latestOnAmplitude.value(amp, elapsed) + ampJob?.cancel() + ampJob = scope.launch { + while (isActive && isCapturing) { + val amp = recorder?.pollAmplitude() ?: 0 + val elapsed = + (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) + latestOnAmplitude.value(amp, elapsed) - if (elapsed >= MaxRecordingMs && isCapturing) { - val file = recorder?.stop() - isCapturing = false - recorder = null - val path = file?.absolutePath ?: recordedFilePath - recordedFilePath = null - buzz() - // Always report the outcome, even when the file is unusable, - // or the caller stays stuck showing the waveform. - if (!path.isNullOrBlank()) { - latestOnFinish.value(path) - } else { - latestOnCancel.value() - } - break - } - delay(80) - } - } - - try { - tryAwaitRelease() - } finally { - if (isCapturing) { - // Keep going briefly past the release so the tail is not clipped. - delay(ReleaseTailMs) - } - if (isCapturing) { + if (elapsed >= MaxRecordingMs && isCapturing) { val file = recorder?.stop() isCapturing = false recorder = null val path = file?.absolutePath ?: recordedFilePath recordedFilePath = null + latestOnTrackFinger.value(null) buzz() + // Always report the outcome, even when the file is unusable, + // or the caller stays stuck showing the waveform. if (!path.isNullOrBlank()) { latestOnFinish.value(path) } else { latestOnCancel.value() } + break } - ampJob?.cancel() - ampJob = null + delay(80) } } - ) + + // Track the finger in root coordinates until it lifts, so the composer can + // run the magnetic slide-to-cancel target. A cancelled pointer (stolen by a + // scroller) ends the capture the same way a lift does. + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: continue + buttonCoords?.let { + latestOnTrackFinger.value(it.localToRoot(change.position)) + } + if (!change.pressed) break + } + + // Cancelling discards immediately; sending keeps a short tail so the last + // syllable is not clipped (an early pointer event simply ends the tail). + // The cancel verdict is read BEFORE the tracker is cleared so the composer + // still sees the final finger position. + val cancel = latestShouldCancel.value() + latestOnTrackFinger.value(null) + if (isCapturing && !cancel) { + withTimeoutOrNull(ReleaseTailMs) { awaitPointerEvent() } + } + if (isCapturing) { + val file = recorder?.stop() + isCapturing = false + recorder = null + val path = file?.absolutePath ?: recordedFilePath + recordedFilePath = null + if (cancel) { + path?.let { runCatching { File(it).delete() } } + try { + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + } catch (_: Exception) { + } + latestOnCancel.value() + } else { + buzz() + if (!path.isNullOrBlank()) { + latestOnFinish.value(path) + } else { + latestOnCancel.value() + } + } + } + ampJob?.cancel() + ampJob = null + } } ) { tint -> Icon(