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 c22a1ab4..7111cd1c 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -30,6 +30,7 @@ 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.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -135,23 +136,37 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: fun VoiceRecordOverlay( voice: VoiceNoteController, hoveringCancel: Boolean, + proximity: Float, + magnetPull: Offset, onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit ) { val palette = LocalBitchatPalette.current - // Snappy, slightly overshooting snap for the cancel morph. + // The cancel morph, choreographed for feel: + // - color flows green→red CONTINUOUSLY as the finger approaches (finger-driven, so it + // is perfectly fluid), completing to full red on activation + // - the button leans toward the approaching finger (magnetic pull), chasing it with a + // smooth spring so it lags and settles naturally + // - scale blooms with a soft bounce on activation — no rotation, no wobble val cancelScale by androidx.compose.animation.core.animateFloatAsState( - targetValue = if (hoveringCancel) 1.3f else 1f, + targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity, animationSpec = androidx.compose.animation.core.spring( dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, - stiffness = androidx.compose.animation.core.Spring.StiffnessHigh + stiffness = androidx.compose.animation.core.Spring.StiffnessMedium ), 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" + val pull by androidx.compose.animation.core.animateOffsetAsState( + targetValue = magnetPull, + animationSpec = androidx.compose.animation.core.spring( + dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy, + stiffness = androidx.compose.animation.core.Spring.StiffnessMedium + ), + label = "magnetPull" + ) + val cancelColor = androidx.compose.ui.graphics.lerp( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.error, + if (hoveringCancel) 1f else proximity * 0.85f ) AnimatedVisibility( visible = voice.recording, @@ -177,14 +192,19 @@ fun VoiceRecordOverlay( ) } .size(52.dp) - .graphicsLayer { scaleX = cancelScale; scaleY = cancelScale } + .graphicsLayer { + translationX = pull.x + translationY = pull.y + scaleX = cancelScale + scaleY = cancelScale + } .clip(CircleShape) .background(cancelColor), contentAlignment = Alignment.Center ) { androidx.compose.animation.Crossfade( targetState = hoveringCancel, - animationSpec = tween(BitchatMotion.QUICK_MS), + animationSpec = tween(BitchatMotion.STANDARD_MS), label = "cancelIcon" ) { cancel -> Icon( 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 f427ca96..5f740f12 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -154,6 +154,24 @@ private fun ChatBody( val hoveringCancel = fingerActive && cancelBounds?.inflate(CANCEL_HOVER_SLANT_PX)?.contains(fingerPos) == true + // Magnetic attraction: as the finger approaches the target (but is not on it yet), the + // button leans toward the finger and blushes red in proportion to the closeness; + // only actually entering the activation zone snaps it into full cancel mode. + val cancelCenter = cancelBounds?.center + val proximity: Float + val magnetPull: Offset + if (fingerActive && cancelCenter != null) { + val toFinger = fingerPos - cancelCenter + val dist = toFinger.getDistance() + proximity = ((MAGNET_OUTER_PX - dist) / (MAGNET_OUTER_PX - MAGNET_INNER_PX)) + .coerceIn(0f, 1f) + magnetPull = if (dist > 1f) toFinger * (proximity * MAGNET_PULL_PX / dist) + else Offset.Zero + } else { + proximity = 0f + magnetPull = Offset.Zero + } + // Tactile tick each time the finger enters or leaves the cancel target. var hoverHapticState by remember { mutableStateOf(false) } LaunchedEffect(hoveringCancel, voice.recording) { @@ -264,6 +282,8 @@ private fun ChatBody( VoiceRecordOverlay( voice = voice, hoveringCancel = hoveringCancel, + proximity = proximity, + magnetPull = magnetPull, onCancelBounds = { cancelBounds = it } ) } @@ -272,3 +292,9 @@ private fun ChatBody( // 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 +// Magnetic zone geometry (px at watch density): the button starts reacting at +// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation +// boundary); it leans toward the finger by up to MAGNET_PULL_PX. +private const val MAGNET_OUTER_PX = 170f +private const val MAGNET_INNER_PX = 104f +private const val MAGNET_PULL_PX = 22f