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

This commit is contained in:
callebtc 2026-07-29 02:22:40 +02:00
parent 2e3f7bf827
commit 18831c20fa
4 changed files with 112 additions and 17 deletions

View File

@ -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)
)

View File

@ -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<Rect?>(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

View File

@ -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()

View File

@ -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)