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 e7cf0e19..fc75445a 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatActionBar.kt @@ -94,8 +94,13 @@ fun ChatActionBar(onKeyboard: () -> Unit, voice: VoiceNoteController, modifier: } else { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } - tryAwaitRelease() - voice.stop(send = true) + // Only a clean release stops here. If the scroll parent steals + // the pointer mid-drag (cancel), keep recording — the + // screen-level release watcher in ChatScaffold stops when the + // finger actually lifts, anywhere on the screen. + if (tryAwaitRelease()) { + voice.stop(send = true) + } } ) }, @@ -167,7 +172,7 @@ fun VoiceRecordOverlay(voice: VoiceNoteController) { modifier = Modifier.padding(top = 10.dp) ) Text( - text = "Release to send", + text = "Lift finger to send", style = ChatVisualTokens.SystemActionStyle, color = palette.textTertiary, textAlign = TextAlign.Center, 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 52b8e1fc..9196ae38 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScaffold.kt @@ -22,8 +22,10 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.wear.compose.foundation.lazy.TransformingLazyColumn @@ -61,16 +63,16 @@ fun ChatScaffold( header: @Composable (expanded: Boolean) -> Unit, actionBar: @Composable () -> Unit ) { - val haptics = LocalHapticFeedback.current val columnState = rememberTransformingLazyColumnState() // Haptics on incoming messages. + val context = LocalContext.current var previousCount by remember { mutableStateOf(messages.size) } LaunchedEffect(messages.size) { if (messages.size > previousCount) { val last = messages.lastOrNull() if (last != null && last.senderPeerID != myPeerID) { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) + WearHaptics.knock(context) } } previousCount = messages.size @@ -139,7 +141,24 @@ private fun ChatBody( ) { val palette = LocalBitchatPalette.current val transformationSpec = rememberTransformationSpec() - Box(modifier = modifier.fillMaxSize()) { + 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. + .pointerInput(voice) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (voice.recording && event.changes.any { it.changedToUp() }) { + voice.stop(send = true) + } + } + } + } + ) { ScreenScaffold(scrollState = columnState) { TransformingLazyColumn( state = columnState, diff --git a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt index ad9e60e2..f73891f9 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/TextInputScreen.kt @@ -49,6 +49,7 @@ import com.bitchat.watch.ui.theme.LocalBitchatPalette @Composable fun TextInputScreen(onSend: (String) -> Unit) { val palette = LocalBitchatPalette.current + val context = androidx.compose.ui.platform.LocalContext.current var text by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current @@ -61,6 +62,7 @@ fun TextInputScreen(onSend: (String) -> Unit) { ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) ?.firstOrNull() if (!spoken.isNullOrBlank()) { + WearHaptics.tick(context) onSend(spoken.trim()) } } @@ -70,6 +72,7 @@ fun TextInputScreen(onSend: (String) -> Unit) { val trimmed = text.trim() if (trimmed.isNotEmpty()) { keyboardController?.hide() + WearHaptics.tick(context) onSend(trimmed) text = "" } 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 a901caea..5d4fcd54 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/VoiceNoteController.kt @@ -50,6 +50,7 @@ class VoiceNoteController( elapsedMs = 0L liveSamples = FloatArray(LIVE_BARS) recording = true + WearHaptics.knock(context) pollJob = scope.launch { while (true) { delay(AMPLITUDE_POLL_MS) @@ -68,6 +69,7 @@ class VoiceNoteController( fun stop(send: Boolean) { if (!recording) return recording = false + 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 new file mode 100644 index 00000000..bcf3186c --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/WearHaptics.kt @@ -0,0 +1,32 @@ +package com.bitchat.watch.ui + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager + +/** + * Tactile accents for the watch's important moments. Uses predefined vibration effects so + * the feel stays consistent with the rest of Wear OS. + */ +object WearHaptics { + /** Firm knock: recording started, message received. */ + fun knock(context: Context) = vibrate(context, VibrationEffect.EFFECT_HEAVY_CLICK) + + /** Crisp click: recording stopped, message sent. */ + fun click(context: Context) = vibrate(context, VibrationEffect.EFFECT_CLICK) + + /** Light tick: small confirmations. */ + fun tick(context: Context) = vibrate(context, VibrationEffect.EFFECT_TICK) + + private fun vibrate(context: Context, effect: Int) { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Vibrator::class.java) + } ?: return + vibrator.vibrate(VibrationEffect.createPredefined(effect)) + } +}