From 893e7d3875ac2bae970d43df1e511bd24ba00768 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:11:11 +0200 Subject: [PATCH] input bar fixes --- .../com/bitchat/android/ui/AboutSections.kt | 54 --- .../java/com/bitchat/android/ui/AboutSheet.kt | 56 +-- .../java/com/bitchat/android/ui/ChatHeader.kt | 34 +- .../com/bitchat/android/ui/InputComponents.kt | 401 ++++++++++++------ .../bitchat/android/ui/LocationNotesButton.kt | 25 +- .../android/ui/VoiceInputComponents.kt | 48 +-- .../android/ui/media/ImagePickerButton.kt | 49 +-- .../android/ui/theme/BitchatPalette.kt | 15 + app/src/main/res/values/strings.xml | 4 - 9 files changed, 382 insertions(+), 304 deletions(-) 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 3df4fe5d..ee432a26 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -6,7 +6,6 @@ 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.Warning import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Lock @@ -452,59 +451,6 @@ private fun AboutFeatureRow( } } -/** - * Security-audit warning banner. - * - * Deliberately styled as a tinted, outlined card rather than plain red text: it needs to be - * impossible to skim past, but it is also permanent until the audit lands, so a full-bleed - * alarm would quickly become wallpaper. - */ -@Composable -internal fun AboutWarningCard(modifier: Modifier = Modifier) { - val palette = LocalBitchatPalette.current - - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = palette.accentRed.copy(alpha = 0.12f), - border = BorderStroke(1.dp, palette.accentRed.copy(alpha = 0.35f)), - shape = RoundedCornerShape(12.dp) - ) { - Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - tint = palette.accentRed, - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource(R.string.about_warning_title).uppercase(), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.8.sp, - color = palette.accentRed - ) - } - Text( - text = stringResource(R.string.about_warning_body), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - lineHeight = 17.sp, - color = palette.accentRed.copy(alpha = 0.85f) - ) - } - } -} - /** * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. */ diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 9b3d6836..6be3cff1 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -2,9 +2,6 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState @@ -24,7 +21,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -41,52 +37,6 @@ import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette -/** - * Feature row for displaying app capabilities - */ -@Composable -private fun FeatureRow( - icon: ImageVector, - title: String, - subtitle: String -) { - val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 13.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 1.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, - color = palette.textPrimary - ) - Text( - text = subtitle, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textSecondary, - lineHeight = 17.sp - ) - } - } -} - /** * Theme selection chip with Apple-like styling */ @@ -566,10 +516,6 @@ fun AboutSheet( } } - // Security audit warning - item(key = "warning") { - AboutWarningCard(modifier = Modifier.padding(top = 24.dp)) - } } // end Settings tab // Footer @@ -585,7 +531,7 @@ fun AboutSheet( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - if (onShowDebug != null) { + if (selectedTab == AboutTab.Settings && onShowDebug != null) { TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), 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 6576a712..052201b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -510,20 +510,26 @@ private fun MainHeader( } } - // Location Notes button (extracted to separate component) - LocationNotesButton( - viewModel = viewModel, - onClick = onLocationNotesClick - ) + // Location notes + channel badge: one tight unit so the document glyph and the + // bluetooth/globe glyph sit at the same visual pitch as other header pairings. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) - // Bookmarking lives in the Location Channels sheet, one tap away via the channel - // button below. Duplicating it here bought a shortcut for a rare action at the cost - // of a slot in the app's most crowded row. + // Bookmarking lives in the Location Channels sheet, one tap away via the channel + // button. Duplicating it here bought a shortcut for a rare action at the cost + // of a slot in the app's most crowded row. - LocationChannelsButton( - viewModel = viewModel, - onClick = onLocationChannelsClick - ) + LocationChannelsButton( + viewModel = viewModel, + onClick = onLocationChannelsClick + ) + } PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, @@ -574,7 +580,9 @@ private fun LocationChannelsButton( .clip(HeaderClusterShape) .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } .height(HeaderTapTarget) - .padding(horizontal = 6.dp) + // No start padding: the notes icon is paired directly to the left; keep end + // padding so the gap to PeerCounter matches other cluster separations. + .padding(start = 0.dp, end = 6.dp) ) { Icon( imageVector = badgeIcon, 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 938729ce..e97a1c95 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -4,10 +4,26 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform 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.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,7 +34,11 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource @@ -174,23 +194,96 @@ class CombinedVisualTransformation(private val transformations: List Unit +) { + val palette = LocalBitchatPalette.current + val accent = if (activeColor == Color.Unspecified) palette.accentGreen else activeColor + + 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, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonContainer" + ) + val tint by animateColorAsState( + targetValue = if (isActive) accent else palette.textSecondary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonTint" + ) + // A small dip on press. Spring rather than tween so the release overshoots very slightly and + // the button feels physical instead of merely animated. + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.88f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "composerButtonScale" + ) + + Box( + modifier = modifier.size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .background(container, CircleShape) + .semantics { contentDescription?.let { this.contentDescription = it } }, + contentAlignment = Alignment.Center + ) { + content(tint) + } + } +} @Composable fun MessageInput( @@ -206,60 +299,72 @@ fun MessageInput( showMediaButtons: Boolean, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current val isFocused = remember { mutableStateOf(false) } - val hasText = value.text.isNotBlank() // Check if there's text for send button state - val keyboard = LocalSoftwareKeyboardController.current + val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } var isRecording by remember { mutableStateOf(false) } var elapsedMs by remember { mutableStateOf(0L) } var amplitude by remember { mutableStateOf(0) } - // Recording turns the pill's outline red; a cross-fade keeps that from flashing. + // Recording is the one state worth shouting about, so it overrides focus. val borderColor by animateColorAsState( targetValue = when { - isRecording -> palette.accentRed.copy(alpha = 0.6f) - isFocused.value -> palette.outline - else -> palette.outlineVariant + isRecording -> palette.accentRed.copy(alpha = 0.7f) + 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. + val containerColor by animateColorAsState( + targetValue = if (isFocused.value) palette.surfaceVariant else palette.surface, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerContainer" + ) Row( - modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), - // Bottom-aligned so that when the field grows to several lines the send button and the - // media buttons stay pinned next to the last line, as in the design's long-text state. - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(4.dp) + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom ) { - // MARK: - The pill. Holds the field and the send button as one visual object. + // MARK: - The pill. Field and action buttons are one visual object. Row( modifier = Modifier .weight(1f) .heightIn(min = ComposerMinHeight) - .background(palette.surfaceVariant, ComposerShape) + // Grow smoothly as the field wraps to more lines rather than jumping a line at + // a time. + .animateContentSize( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ) + .background(containerColor, ComposerShape) .border(1.dp, borderColor, ComposerShape), verticalAlignment = Alignment.Bottom ) { Box( modifier = Modifier .weight(1f) - .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp) + .padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp) ) { // Always keep the text field mounted to retain focus and avoid IME collapse BasicTextField( value = value, onValueChange = onValueChange, + // Near-white, not terminal green: this is the one place in the app where the + // user is composing rather than reading, and green-on-black is tiring to + // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.primary, + color = palette.textPrimary, fontFamily = FontFamily.Monospace ), - cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), + cursorBrush = SolidColor( + if (isRecording) Color.Transparent else palette.textPrimary + ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text + if (hasText) onSend() }), // Cap the growth so a pasted wall of text cannot swallow the message list. maxLines = 6, @@ -285,34 +390,52 @@ fun MessageInput( } ) - // Show placeholder when there's no text and not recording - if (value.text.isEmpty() && !isRecording) { + // Placeholder fades rather than blinking, which matters because it reappears + // every time a message is sent. + val placeholderAlpha by animateFloatAsState( + targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "placeholderAlpha" + ) + if (placeholderAlpha > 0f) { Text( text = stringResource(R.string.type_a_message_placeholder), style = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace ), color = palette.textTertiary, - modifier = Modifier.fillMaxWidth() + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .alpha(placeholderAlpha) ) } - // Overlay the real-time scrolling waveform while recording + // Recording visualiser, layered over the (empty) field. + val waveformAlpha by animateFloatAsState( + targetValue = if (isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "waveformAlpha" + ) if (isRecording) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(waveformAlpha), + verticalAlignment = Alignment.CenterVertically + ) { RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(24.dp), + modifier = Modifier.weight(1f).height(22.dp), amplitudeNorm = normalizeAmplitudeSample(amplitude) ) Spacer(Modifier.width(12.dp)) val secs = (elapsedMs / 1000).toInt() - val mm = secs / 60 - val ss = secs % 60 val maxSecs = 10 // 10 second max recording time - val maxMm = maxSecs / 60 - val maxSs = maxSecs % 60 Text( - text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs), + text = String.format( + "%02d:%02d / %02d:%02d", + secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 + ), fontFamily = FontFamily.Monospace, color = palette.accentRed, fontSize = (BASE_FONT_SIZE - 4).sp @@ -321,60 +444,116 @@ fun MessageInput( } } - SendButton( - hasText = hasText, - isAccented = selectedPrivatePeer != null || currentChannel != null, - onSend = onSend, - modifier = Modifier.padding(end = 4.dp, bottom = 4.dp) - ) - } - - // MARK: - Media affordances, outside the pill, only while the field is empty. - if (value.text.isEmpty() && showMediaButtons) { - // Ensure latest values are used when finishing recording + // MARK: - Action cluster, inside the pill. + // + // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and + // scales between the two, and SizeTransform animates the width change, so typing the + // first character morphs camera+mic into send instead of snapping. val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestChannel = rememberUpdatedState(currentChannel) val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote) - // Image button (image picker) - hide during recording - if (!isRecording) { - ImagePickerButton( - onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) + AnimatedContent( + targetState = hasText, + transitionSpec = { + ( + fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.7f, + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + ).togetherWith( + fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.7f, + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ) + ) + ) using SizeTransform(clip = false) { _, _ -> + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) } - ) - } + }, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp), + label = "composerActions" + ) { showSend -> + if (showSend) { + SendButton( + isAccented = latestSelectedPeer.value != null || latestChannel.value != null, + onSend = onSend + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showMediaButtons) { + // The camera steps aside while recording so the microphone is the + // only thing that can be released. + 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) + ) + ) { + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote( + latestSelectedPeer.value, + latestChannel.value, + outPath + ) + } + ) + } - VoiceRecordButton( - backgroundColor = colorScheme.primary, - onStart = { - isRecording = true - elapsedMs = 0L - // Keep existing focus to avoid IME collapse, but do not force-show keyboard - if (isFocused.value) { - try { focusRequester.requestFocus() } catch (_: Exception) {} - } - }, - onAmplitude = { amp, ms -> - amplitude = amp - elapsedMs = ms - }, - onFinish = { path -> - isRecording = false - // Extract and cache waveform from the actual audio file to match receiver rendering - AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> - if (arr != null) { - try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {} + VoiceRecordButton( + isRecording = isRecording, + onStart = { + isRecording = true + elapsedMs = 0L + // Keep existing focus to avoid IME collapse, but do not + // force-show the keyboard. + if (isFocused.value) { + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + }, + onAmplitude = { amp, ms -> + amplitude = amp + elapsedMs = ms + }, + onFinish = { path -> + isRecording = false + // Extract and cache the waveform from the actual audio file + // so it matches the receiver's rendering. + AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> + if (arr != null) { + try { + com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) + } catch (_: Exception) {} + } + } + latestOnSendVoiceNote.value( + latestSelectedPeer.value, + latestChannel.value, + path + ) + } + ) + } else { + // No media in this context, so keep an inert send button rather than + // leaving a hole where the action cluster should be. + SendButton(isAccented = false, onSend = {}, enabled = false) } } - // BLE path (private or public) — use latest values to avoid stale captures - latestOnSendVoiceNote.value( - latestSelectedPeer.value, - latestChannel.value, - path - ) } - ) + } } } @@ -382,51 +561,31 @@ fun MessageInput( } /** - * Circular send affordance nested in the bottom-right of the composer pill. - * - * Goes from a flat grey disc to a solid accent the moment there is something to send, which is - * the clearest possible signal that the return key will do something. + * 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. */ @Composable private fun SendButton( - hasText: Boolean, isAccented: Boolean, onSend: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { val palette = LocalBitchatPalette.current - val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() - val targetBackground = when { - !hasText -> palette.outline - isAccented -> palette.accentOrange - else -> colorScheme.primary - } - val targetTint = when { - !hasText -> palette.textTertiary - // Both accents are bright enough that black is the only legible arrow colour. - else -> Color.Black - } - - val background by animateColorAsState( - targetValue = targetBackground, - animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), - label = "sendButtonBackground" - ) - val tint by animateColorAsState( - targetValue = targetTint, - animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), - label = "sendButtonTint" - ) - - Box( - modifier = modifier - .size(SendButtonSize) - .background(background, CircleShape) - .clip(CircleShape) - .clickable(enabled = hasText) { onSend() }, - contentAlignment = Alignment.Center - ) { + ComposerActionSurface( + isActive = enabled, + isPressed = isPressed, + // Private chats and channels keep their orange identity, disc and glyph together. + activeColor = if (isAccented) palette.accentOrange else palette.accentGreen, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onSend() } + ) { tint -> Icon( imageVector = Icons.Filled.ArrowUpward, contentDescription = stringResource(id = R.string.send_message), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 1ec6417e..185a053e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -2,16 +2,19 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -34,7 +37,7 @@ fun LocationNotesButton( ) { val colorScheme = MaterialTheme.colorScheme val context = LocalContext.current - + // Get channel and permission state val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } @@ -44,7 +47,7 @@ fun LocationNotesButton( // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationEnabled = locationPermissionGranted && locationServicesEnabled - + // Get notes count from LocationNotesManager val notesManager = remember { LocationNotesManager.getInstance() } val notes by notesManager.notes.collectAsStateWithLifecycle() @@ -53,13 +56,19 @@ fun LocationNotesButton( // Only show in mesh mode when location is authorized (iOS pattern) if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 - IconButton( - onClick = onClick, - modifier = modifier.size(44.dp) + val contentDescription = stringResource(R.string.cd_location_notes) + // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding + // that pushed the notes glyph farther from the mesh badge than sibling gaps. + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .clickable(onClickLabel = contentDescription) { onClick() }, + contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Outlined.Description, - contentDescription = stringResource(R.string.cd_location_notes), + contentDescription = contentDescription, modifier = Modifier.size(HeaderIconSize), tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary ) 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 cdc40583..bda3aee4 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -3,22 +3,16 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic import android.Manifest -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.unit.dp import com.bitchat.android.features.voice.VoiceRecorder import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus @@ -32,7 +26,11 @@ import kotlinx.coroutines.launch @Composable fun VoiceRecordButton( modifier: Modifier = Modifier, - backgroundColor: Color, + /** + * Recording state as the composer sees it. Drives the active tint so the button and the + * pill's border change together instead of one lagging the other. + */ + isRecording: Boolean = false, onStart: () -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, onFinish: (filePath: String) -> Unit @@ -41,7 +39,7 @@ fun VoiceRecordButton( val haptic = LocalHapticFeedback.current val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - var isRecording by remember { mutableStateOf(false) } + var isCapturing by remember { mutableStateOf(false) } var recorder by remember { mutableStateOf(null) } var recordedFilePath by remember { mutableStateOf(null) } var recordingStart by remember { mutableStateOf(0L) } @@ -54,14 +52,15 @@ fun VoiceRecordButton( val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) - Box( + // Same disc, same sizing and the same press feedback as the camera and send buttons. + ComposerActionSurface( + isActive = isRecording || isCapturing, + isPressed = isCapturing, modifier = modifier - .size(36.dp) - .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isRecording) { + if (!isCapturing) { if (micPermission.status !is PermissionStatus.Granted) { micPermission.launchPermissionRequest() return@detectTapGestures @@ -69,24 +68,24 @@ fun VoiceRecordButton( val rec = VoiceRecorder(context) val f = rec.start() recorder = rec - isRecording = f != null + isCapturing = f != null recordedFilePath = f?.absolutePath recordingStart = System.currentTimeMillis() - if (isRecording) { + 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 && isRecording) { + 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 && isRecording) { + if (elapsedMs >= 10_000 && isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null val path = file?.absolutePath if (!path.isNullOrBlank()) { @@ -104,13 +103,13 @@ fun VoiceRecordButton( try { awaitRelease() } finally { - if (isRecording) { + if (isCapturing) { // Extend recording for 500ms after release to avoid clipping delay(500) } - if (isRecording) { + if (isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null val path = (file?.absolutePath ?: recordedFilePath) recordedFilePath = null @@ -125,14 +124,13 @@ fun VoiceRecordButton( } } ) - }, - contentAlignment = Alignment.Center - ) { + } + ) { tint -> Icon( imageVector = Icons.Filled.Mic, contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), - tint = Color.Black, - modifier = Modifier.size(22.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index df6f8ea7..97ee01a4 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -11,22 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.bitchat.android.features.media.ImageUtils -import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.ComposerActionSurface +import com.bitchat.android.ui.ComposerIconSize import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -89,28 +87,31 @@ fun ImagePickerButton( } } - Box( - modifier = modifier - // Matches the header's tap targets and the composer's send button. - .size(44.dp) - .clip(CircleShape) - .combinedClickable( - onClick = { imagePicker.launch("image/*") }, - onLongClick = { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - startCameraCapture() - } else { - permissionLauncher.launch(Manifest.permission.CAMERA) - } + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // Shares the composer's button treatment so camera, microphone and send read as one set. + ComposerActionSurface( + isActive = false, + isPressed = isPressed, + modifier = modifier.combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = { imagePicker.launch("image/*") }, + onLongClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCameraCapture() + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) } - ), - contentAlignment = Alignment.Center - ) { + } + ) + ) { tint -> Icon( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), - tint = LocalBitchatPalette.current.textSecondary, - modifier = Modifier.size(22.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } 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 bc5e7b03..e7f1a9af 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 @@ -33,6 +33,17 @@ data class BitchatPalette( /** Hairline dividers. */ val outlineVariant: Color, + // MARK: - Form controls + /** + * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted + * [outline]: the composer is the one surface the user stares at while typing, and a green + * cast there made the whole bottom of the screen read as "terminal chrome" instead of a + * place to write. + */ + val inputOutline: Color, + /** Border for a focused text input. A step brighter, still neutral. */ + val inputOutlineFocused: Color, + // MARK: - Text /** Message bodies and row titles. Neutral, not green. */ val textPrimary: Color, @@ -64,6 +75,8 @@ val DarkBitchatPalette = BitchatPalette( surfaceVariant = Color(0xFF182118), outline = Color(0xFF2A3A2A), outlineVariant = Color(0xFF1C271C), + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), textPrimary = Color(0xFFE8EDE8), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), @@ -81,6 +94,8 @@ val LightBitchatPalette = BitchatPalette( surfaceVariant = Color(0xFFE7EDE7), outline = Color(0xFFCBD6CB), outlineVariant = Color(0xFFDEE6DE), + inputOutline = Color(0xFFCFD3D1), + inputOutlineFocused = Color(0xFF8E9490), textPrimary = Color(0xFF131A13), textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62d3ad1d..3b7a7022 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,10 +135,6 @@ A new peer ID is generated regularly. Triple-tap the logo to instantly clear all data. - - Warning - Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears. - Tor Network Difficulty