diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index ee432a26..af17a509 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -4,7 +4,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.outlined.BookmarkBorder @@ -339,71 +338,40 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { } /** - * The six-item capability list from the design. - * - * Icons are Material approximations of the designer's custom line art; swap in the exported - * SVGs when they are available. + * Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers. */ @Composable internal fun AboutFeatureCard(modifier: Modifier = Modifier) { - val palette = LocalBitchatPalette.current + val features = listOf( + Triple( + Icons.Filled.WifiOff, + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc + ), + Triple( + Icons.Outlined.Lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + Icons.Outlined.Public, + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc + ), + Triple( + Icons.Outlined.VisibilityOff, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + ) - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - val features = listOf( - Triple( - Icons.Filled.WifiOff, - R.string.about_offline_mesh_title, - R.string.about_offline_mesh_desc - ), - Triple( - Icons.Outlined.Lock, - R.string.about_e2e_title, - R.string.about_e2e_desc - ), - Triple( - Icons.Outlined.Public, - R.string.about_online_geohash_title, - R.string.about_online_geohash_desc - ), - Triple( - Icons.Outlined.VisibilityOff, - R.string.about_no_tracking_title, - R.string.about_no_tracking_desc - ), - Triple( - Icons.Filled.Shuffle, - R.string.about_ephemeral_title, - R.string.about_ephemeral_desc - ), - Triple( - Icons.Filled.DeleteForever, - R.string.about_emergency_title, - R.string.about_panic_desc - ), + Column(modifier = modifier.fillMaxWidth()) { + features.forEach { (icon, titleRes, descRes) -> + AboutFeatureRow( + icon = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) ) - - features.forEachIndexed { index, (icon, titleRes, descRes) -> - if (index > 0) { - HorizontalDivider( - // Inset to align with the text column, not the icon. - modifier = Modifier.padding(start = 54.dp), - thickness = 1.dp, - color = palette.outlineVariant - ) - } - AboutFeatureRow( - icon = icon, - title = stringResource(titleRes), - subtitle = stringResource(descRes) - ) - } } } } @@ -420,7 +388,8 @@ private fun AboutFeatureRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 13.dp), + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top ) { Icon( @@ -429,15 +398,15 @@ private fun AboutFeatureRow( tint = colorScheme.primary, modifier = Modifier .padding(top = 1.dp) - .size(24.dp) + .size(22.dp) ) - Spacer(modifier = Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = title, fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium, + lineHeight = 20.sp, color = palette.textPrimary ) Text( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 052201b0..c6cb546f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -560,7 +560,6 @@ private fun LocationChannelsButton( // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location val badgeText = when (val channel = selectedChannel) { @@ -599,15 +598,5 @@ private fun LocationChannelsButton( color = badgeColor, maxLines = 1 ) - - // Teleportation indicator (like iOS) - if (teleported) { - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(HeaderIconSize), - tint = badgeColor - ) - } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6f4ff8a3..2d721bba 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -14,11 +14,14 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -120,7 +123,14 @@ fun ChatScreen(viewModel: ChatViewModel) { .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { val headerHeight = ChatHeaderHeight - + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + // Both bars are translucent and the conversation scrolls underneath them, so their + // heights are reserved as list padding instead of as layout space. The composer's height + // varies (suggestion rows, wrapped lines), so it is measured rather than assumed. + var composerHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -128,19 +138,17 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded ) { - // Header spacer - creates exact space for the floating header (status bar + compact header) - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(headerHeight) - ) - + Box(modifier = Modifier.weight(1f)) { // Messages area - takes up available space, will compress when keyboard appears MessagesList( messages = displayMessages, currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = statusBarHeight + headerHeight, + bottom = composerHeight + ), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { fullSenderName -> @@ -188,7 +196,7 @@ fun ChatScreen(viewModel: ChatViewModel) { showFullScreenImageViewer = true } ) - // Input area - stays at bottom + // Input area - overlays the bottom of the conversation // Bridge file share from lower-level input to ViewModel androidx.compose.runtime.LaunchedEffect(Unit) { com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path -> @@ -197,6 +205,11 @@ fun ChatScreen(viewModel: ChatViewModel) { } ChatInputSection( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + composerHeight = with(density) { size.height.toDp() } + }, messageText = messageText, onMessageTextChange = { newText: TextFieldValue -> messageText = newText @@ -244,6 +257,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, showMediaButtons = showMediaButtons ) + } } // Floating header - positioned absolutely at top, ignores keyboard @@ -261,17 +275,6 @@ fun ChatScreen(viewModel: ChatViewModel) { onLocationNotesClick = { showLocationNotesSheet = true } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - thickness = 1.dp, - color = palette.outlineVariant - ) - // Scroll-to-bottom floating button AnimatedVisibility( visible = isScrolledUp, @@ -287,7 +290,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) + fadeOut(tween(BitchatMotion.QUICK_MS)), modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 64.dp) + .padding(end = 16.dp, bottom = composerHeight + 8.dp) .zIndex(1.5f) .windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.ime) @@ -407,50 +410,73 @@ fun ChatInputSection( currentChannel: String?, nickname: String, colorScheme: ColorScheme, - showMediaButtons: Boolean + showMediaButtons: Boolean, + modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.background + + Column( + // Flat, slightly translucent screen background — the same treatment as the top bar, so the + // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked + // like a smudge above a crisp hairline. The rule is inside the background so the whole bar + // is one surface with a top border, rather than a line floating over the conversation. + modifier = modifier + .fillMaxWidth() + .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) ) { - // No divider above the composer: the pill's own border provides the separation, and a - // full-width rule on top of it read as a double line. - Column { - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = onCommandSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) - } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { - MentionSuggestionsBox( - suggestions = mentionSuggestions, - onSuggestionClick = onMentionSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) - } - MessageInput( - value = messageText, - onValueChange = onMessageTextChange, - onSend = onSend, - onSendVoiceNote = onSendVoiceNote, - onSendImageNote = onSendImageNote, - onSendFileNote = onSendFileNote, - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - showMediaButtons = showMediaButtons, + // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } + // Mention suggestions box + if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { + MentionSuggestionsBox( + suggestions = mentionSuggestions, + onSuggestionClick = onMentionSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + } + MessageInput( + value = messageText, + onValueChange = onMessageTextChange, + onSend = onSend, + onSendVoiceNote = onSendVoiceNote, + onSendImageNote = onSendImageNote, + onSendFileNote = onSendFileNote, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + showMediaButtons = showMediaButtons, + modifier = Modifier.fillMaxWidth() + ) } } + +/** + * Opacity shared by both bars. + * + * Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads + * as sitting over the content rather than boxing it in — without ever costing legibility. + */ +private const val BarBackgroundAlpha = 0.88f + +/** + * Fraction of the header that stays fully opaque, measured from the top. + * + * The header is the one place a gradient earns its keep: the status bar is transparent, so the + * header has to be the true background colour where the two meet or the system bar stops looking + * like part of the app. Everything below that stop matches the composer's flat translucency. + */ +private const val HeaderOpaqueStop = 0.72f @Composable private fun ChatFloatingHeader( headerHeight: Dp, @@ -467,13 +493,24 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - - Surface( + val palette = LocalBitchatPalette.current + + Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area - color = colorScheme.background // Solid background color extending into status bar + // Fully opaque where it meets the system status bar, fading to translucent at its + // lower edge. The status bar itself is transparent, so anything less than opaque at + // the top would let the wallpaper or a light system-bar scrim bleed through and the + // header would stop reading as part of the app. + .background( + Brush.verticalGradient( + 0f to colorScheme.background, + HeaderOpaqueStop to colorScheme.background, + 1f to colorScheme.background.copy(alpha = BarBackgroundAlpha) + ) + ) + .windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area ) { // A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal // pad plus a 12.dp title inset and applies its own minimum heights, which made the diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index e97a1c95..d673bb79 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -220,6 +220,15 @@ private val ComposerButtonDisc = 36.dp /** Icon size shared by the composer's glyphs. */ internal val ComposerIconSize = 20.dp +/** + * Opacity of the composer pill. + * + * Not fully opaque: the message list scrolls underneath the composer, and letting a hint of it + * through is what makes the bar read as sitting *over* the conversation rather than boxing it in. + * Kept high enough that text in the field never loses contrast. + */ +private const val ComposerFillAlpha = 0.88f + /** * The shared visual treatment for every button in the composer: camera, microphone, send. * @@ -248,7 +257,7 @@ internal fun ComposerActionSurface( val container by animateColorAsState( // A tint rather than a fill. A solid accent disc next to the text you are typing was the // loudest thing on the screen; at 20% it still reads as "armed" without competing. - targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.surfaceVariant, + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerButtonContainer" ) @@ -318,9 +327,10 @@ fun MessageInput( label = "composerBorder" ) // A barely-there lift on focus. Enough to register, not enough to look like a different - // component. + // component. Slightly translucent so the messages scrolling underneath stay faintly visible. val containerColor by animateColorAsState( - targetValue = if (isFocused.value) palette.surfaceVariant else palette.surface, + targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha), animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerContainer" ) @@ -544,6 +554,14 @@ fun MessageInput( latestChannel.value, path ) + }, + // Any capture that ends without a file must clear the recording + // state here too, otherwise the pill stays red with a live + // waveform over an empty field. + onCancel = { + isRecording = false + amplitude = 0 + elapsedMs = 0L } ) } else { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 180b35c8..880c0cc6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -860,6 +860,7 @@ fun PrivateChatSheet( val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) + val palette = LocalBitchatPalette.current if (isPresented) { BitchatBottomSheet( @@ -872,7 +873,7 @@ fun PrivateChatSheet( ) { Spacer(modifier = Modifier.height(64.dp)) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } @@ -891,9 +892,8 @@ fun PrivateChatSheet( onImageClick = { _, _, _ -> /* handle image click */ } ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - - // Input section + // Input section. No divider here: ChatInputSection draws its own fade and + // hairline. var messageText by remember { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 83db678a..ad763aa7 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -37,6 +39,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -72,6 +75,13 @@ fun MessagesList( currentUserNickname: String, meshService: MeshService, modifier: Modifier = Modifier, + /** + * Extra inset on top of the list's own gutters. + * + * The chat screen's bars are translucent and the list scrolls underneath them, so the caller + * has to reserve room for their heights here rather than by shrinking the viewport. + */ + contentPadding: PaddingValues = PaddingValues(0.dp), forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, @@ -119,11 +129,17 @@ fun MessagesList( } } + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, // Wider side gutters than the old 12.dp: the redesign trades a little line length for // a much calmer edge, and long monospace lines were running into the screen bezel. - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), + contentPadding = PaddingValues( + start = 16.dp + contentPadding.calculateStartPadding(layoutDirection), + end = 16.dp + contentPadding.calculateEndPadding(layoutDirection), + top = 8.dp + contentPadding.calculateTopPadding(), + bottom = 12.dp + contentPadding.calculateBottomPadding() + ), // Spacing is owned by each item so that a continuation of the same author can sit // tighter than the start of a new speaker's run. verticalArrangement = Arrangement.spacedBy(0.dp), diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index bda3aee4..c9922bf1 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -21,6 +21,34 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * How long the button must be held before a recording starts. + * + * Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a + * recording, and it also made the control impossible to defend against pointer events it should + * never have seen (see [ArmDelayMs]). + */ +private const val HoldToRecordMs = 220L + +/** + * How long the button ignores presses after entering composition. + * + * The action cluster swaps send out for camera+microphone the instant the field is cleared, which + * puts the microphone exactly where the send button was a frame earlier. Tapping send quickly + * could hand the microphone a pointer-down whose matching pointer-up had already been delivered + * to the send button that no longer exists — leaving the gesture waiting for a release that will + * never come, stuck in "recording" until the composable is disposed. Refusing presses until the + * swap animation has settled removes that whole class of failure. + */ +private const val ArmDelayMs = 350L + +/** Hard cap on a single recording. */ +private const val MaxRecordingMs = 10_000L + +/** Tail kept after release so the last syllable is not clipped. */ +private const val ReleaseTailMs = 500L @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -33,7 +61,13 @@ fun VoiceRecordButton( isRecording: Boolean = false, onStart: () -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, - onFinish: (filePath: String) -> 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. + */ + onCancel: () -> Unit = {} ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current @@ -51,6 +85,35 @@ fun VoiceRecordButton( val latestOnStart = rememberUpdatedState(onStart) val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) + val latestOnCancel = rememberUpdatedState(onCancel) + + // Set when this instance was composed, so presses inherited from whatever occupied this spot + // beforehand can be rejected. + val composedAt = remember { System.currentTimeMillis() } + + fun buzz() { + try { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } catch (_: Exception) { + } + } + + // Last line of defence: if the button is removed while capturing — the cluster swapping to + // send, the sheet closing, the screen going away — release the recorder and tell the caller, + // so nothing is left holding the microphone or showing a recording UI. + DisposableEffect(Unit) { + onDispose { + ampJob?.cancel() + ampJob = null + if (isCapturing) { + isCapturing = false + runCatching { recorder?.stop() } + recorder = null + recordedFilePath = null + latestOnCancel.value() + } + } + } // Same disc, same sizing and the same press feedback as the camera and send buttons. ComposerActionSurface( @@ -60,63 +123,89 @@ fun VoiceRecordButton( .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isCapturing) { - if (micPermission.status !is PermissionStatus.Granted) { - micPermission.launchPermissionRequest() - return@detectTapGestures - } - val rec = VoiceRecorder(context) - val f = rec.start() - recorder = rec - isCapturing = f != null - recordedFilePath = f?.absolutePath - recordingStart = System.currentTimeMillis() - if (isCapturing) { - latestOnStart.value() - // Haptic "knock" when recording starts - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - // Start amplitude polling loop - ampJob?.cancel() - ampJob = scope.launch { - while (isActive && isCapturing) { - val amp = recorder?.pollAmplitude() ?: 0 - val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) - latestOnAmplitude.value(amp, elapsedMs) - // Auto-stop after 10 seconds - if (elapsedMs >= 10_000 && isCapturing) { - val file = recorder?.stop() - isCapturing = false - recorder = null - val path = file?.absolutePath - if (!path.isNullOrBlank()) { - // Haptic "knock" on auto stop - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - latestOnFinish.value(path) - } - break - } - delay(80) + // 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 + + if (micPermission.status !is PermissionStatus.Granted) { + micPermission.launchPermissionRequest() + return@detectTapGestures + } + + // 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 + + 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 + } + + 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) + + 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 { - awaitRelease() + tryAwaitRelease() } finally { if (isCapturing) { - // Extend recording for 500ms after release to avoid clipping - delay(500) + // Keep going briefly past the release so the tail is not clipped. + delay(ReleaseTailMs) } if (isCapturing) { val file = recorder?.stop() isCapturing = false recorder = null - val path = (file?.absolutePath ?: recordedFilePath) + val path = file?.absolutePath ?: recordedFilePath recordedFilePath = null + buzz() if (!path.isNullOrBlank()) { - // Haptic "knock" when recording stops - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} latestOnFinish.value(path) + } else { + latestOnCancel.value() } } ampJob?.cancel() diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index e7f1a9af..697567b3 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -43,6 +43,16 @@ data class BitchatPalette( val inputOutline: Color, /** Border for a focused text input. A step brighter, still neutral. */ val inputOutlineFocused: Color, + /** + * Fill for text inputs. Near-black / near-white and completely untinted, for the same reason + * as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any + * tint of its own compounds into something muddy. + */ + val inputSurface: Color, + /** Fill for a focused text input. A barely perceptible lift. */ + val inputSurfaceFocused: Color, + /** Resting disc behind the composer's action glyphs. Neutral grey. */ + val inputButton: Color, // MARK: - Text /** Message bodies and row titles. Neutral, not green. */ @@ -77,6 +87,9 @@ val DarkBitchatPalette = BitchatPalette( outlineVariant = Color(0xFF1C271C), inputOutline = Color(0xFF333635), inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), textPrimary = Color(0xFFE8EDE8), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), @@ -96,6 +109,9 @@ val LightBitchatPalette = BitchatPalette( outlineVariant = Color(0xFFDEE6DE), inputOutline = Color(0xFFCFD3D1), inputOutlineFocused = Color(0xFF8E9490), + inputSurface = Color(0xFFFAFAFA), + inputSurfaceFocused = Color(0xFFF2F2F2), + inputButton = Color(0xFFE8E8E8), textPrimary = Color(0xFF131A13), textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75),