From 18831c20fa112232dd4c8ead6b7188641363e000 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:22:40 +0200 Subject: [PATCH] wear: slide-to-cancel voice recording - global finger tracking while recording, mic button morphs into red cancel target on approach (tick haptic in/out, double-click reject on cancel), release anywhere sends; PTT release no longer owned by the small mic button; haptics: knock on record start/incoming message, click on stop/send, tick on text send --- .../com/bitchat/watch/ui/ChatActionBar.kt | 64 ++++++++++++++++--- .../java/com/bitchat/watch/ui/ChatScaffold.kt | 58 +++++++++++++++-- .../bitchat/watch/ui/VoiceNoteController.kt | 4 +- .../java/com/bitchat/watch/ui/WearHaptics.kt | 3 + 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt index fc75445a..c22a1ab4 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -22,13 +22,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Keyboard import androidx.compose.material.icons.filled.Mic import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.toSize import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -121,10 +126,33 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: * Full-screen push-to-talk overlay: fades in over the chat with a live waveform, elapsed time, * and a release hint. Rendered as a sibling of the screen content (NOT inside the edgeButton * slot, which would clip it to the slot bounds). + * + * The big mic button doubles as the slide-to-cancel target: when the user's finger approaches + * it ([hoveringCancel]), it snaps into a red cancel button with a bouncy spring; lifting the + * finger there cancels the recording, dragging back out returns to send mode. */ @Composable -fun VoiceRecordOverlay(voice: VoiceNoteController) { +fun VoiceRecordOverlay( + voice: VoiceNoteController, + hoveringCancel: Boolean, + onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit +) { val palette = LocalBitchatPalette.current + // Snappy, slightly overshooting snap for the cancel morph. + val cancelScale by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (hoveringCancel) 1.3f else 1f, + animationSpec = androidx.compose.animation.core.spring( + dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, + stiffness = androidx.compose.animation.core.Spring.StiffnessHigh + ), + label = "cancelSnap" + ) + val cancelColor by androidx.compose.animation.animateColorAsState( + targetValue = if (hoveringCancel) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + animationSpec = tween(BitchatMotion.QUICK_MS), + label = "cancelColor" + ) AnimatedVisibility( visible = voice.recording, enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)), @@ -140,17 +168,32 @@ fun VoiceRecordOverlay(voice: VoiceNoteController) { ) { Box( modifier = Modifier + .onGloballyPositioned { coords -> + onCancelBounds( + androidx.compose.ui.geometry.Rect( + coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero), + coords.size.toSize() + ) + ) + } .size(52.dp) + .graphicsLayer { scaleX = cancelScale; scaleY = cancelScale } .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), + .background(cancelColor), contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Filled.Mic, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(26.dp) - ) + androidx.compose.animation.Crossfade( + targetState = hoveringCancel, + animationSpec = tween(BitchatMotion.QUICK_MS), + label = "cancelIcon" + ) { cancel -> + Icon( + imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic, + contentDescription = if (cancel) "cancel recording" else null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(26.dp) + ) + } } WaveformBars( samples = voice.liveSamples, @@ -172,9 +215,10 @@ fun VoiceRecordOverlay(voice: VoiceNoteController) { modifier = Modifier.padding(top = 10.dp) ) Text( - text = "Lift finger to send", + text = if (hoveringCancel) "Release to cancel" else "Lift finger to send", style = ChatVisualTokens.SystemActionStyle, - color = palette.textTertiary, + color = if (hoveringCancel) MaterialTheme.colorScheme.error + else palette.textTertiary, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 2.dp) ) diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt index 9196ae38..f427ca96 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.changedToUp @@ -140,20 +142,56 @@ private fun ChatBody( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val context = LocalContext.current val transformationSpec = rememberTransformationSpec() + + // Slide-to-cancel: while recording, the finger's position is tracked globally; the + // overlay's mic button reports its bounds and becomes the cancel target when the + // finger hovers it (with generous slack so the snap engages on approach). + var cancelBounds by remember { mutableStateOf(null) } + var fingerPos by remember { mutableStateOf(Offset.Zero) } + var fingerActive by remember { mutableStateOf(false) } + val hoveringCancel = fingerActive && + cancelBounds?.inflate(CANCEL_HOVER_SLANT_PX)?.contains(fingerPos) == true + + // Tactile tick each time the finger enters or leaves the cancel target. + var hoverHapticState by remember { mutableStateOf(false) } + LaunchedEffect(hoveringCancel, voice.recording) { + if (!voice.recording) { + hoverHapticState = false + } else if (hoveringCancel != hoverHapticState) { + WearHaptics.tick(context) + hoverHapticState = hoveringCancel + } + } + Box( modifier = modifier .fillMaxSize() // Push-to-talk release is tracked globally: once recording, lifting the finger - // ANYWHERE on the screen stops and sends. On a 1.4" round screen it is too easy - // to drift off the small mic button (the scrollable parent steals the pointer - // mid-drag), so the button alone must not own the release. + // ANYWHERE on the screen stops — sending, or cancelling when hovering the + // cancel target. On a 1.4" round screen it is too easy to drift off the small + // mic button (the scrollable parent steals the pointer mid-drag), so the + // button alone must not own the release. .pointerInput(voice) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent(PointerEventPass.Initial) - if (voice.recording && event.changes.any { it.changedToUp() }) { - voice.stop(send = true) + if (!voice.recording) continue + val change = event.changes.firstOrNull() ?: continue + fingerPos = change.position + fingerActive = true + if (event.changes.any { it.changedToUp() }) { + val cancel = cancelBounds + ?.inflate(CANCEL_HOVER_SLANT_PX) + ?.contains(change.position) == true + fingerActive = false + if (cancel) { + WearHaptics.reject(context) + voice.stop(send = false) + } else { + voice.stop(send = true) + } } } } @@ -223,6 +261,14 @@ private fun ChatBody( } } - VoiceRecordOverlay(voice) + VoiceRecordOverlay( + voice = voice, + hoveringCancel = hoveringCancel, + onCancelBounds = { cancelBounds = it } + ) } } + +// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap +// engages as the finger approaches, not only on exact contact. +private const val CANCEL_HOVER_SLANT_PX = 56f diff --git a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt index 5d4fcd54..7080c933 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -69,7 +69,9 @@ class VoiceNoteController( fun stop(send: Boolean) { if (!recording) return recording = false - WearHaptics.click(context) + // The send path clicks; the cancel path stays silent here because the caller + // plays its own reject haptic. + if (send) WearHaptics.click(context) pollJob?.cancel() pollJob = null val file = recorder.stop() diff --git a/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt index bcf3186c..6e8fc8e2 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt @@ -17,6 +17,9 @@ object WearHaptics { /** Crisp click: recording stopped, message sent. */ fun click(context: Context) = vibrate(context, VibrationEffect.EFFECT_CLICK) + /** Double tap: destructive/cancel confirmation. */ + fun reject(context: Context) = vibrate(context, VibrationEffect.EFFECT_DOUBLE_CLICK) + /** Light tick: small confirmations. */ fun tick(context: Context) = vibrate(context, VibrationEffect.EFFECT_TICK)