mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
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:
parent
2e3f7bf827
commit
18831c20fa
@ -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)
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user