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

This commit is contained in:
callebtc 2026-07-29 12:20:30 +02:00
parent 3b53b9bdcc
commit 56f52218ed
2 changed files with 294 additions and 94 deletions

View File

@ -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<Rect?>(null) }
var cancelFinger by remember { mutableStateOf<Offset?>(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.

View File

@ -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<VoiceRecorder?>(null) }
var recordedFilePath by remember { mutableStateOf<String?>(null) }
var recordingStart by remember { mutableStateOf(0L) }
var buttonCoords by remember { mutableStateOf<LayoutCoordinates?>(null) }
val scope = rememberCoroutineScope()
var ampJob by remember { mutableStateOf<Job?>(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(