input bar fixes

This commit is contained in:
callebtc 2026-07-27 03:11:11 +02:00
parent 53a891a71f
commit 893e7d3875
9 changed files with 382 additions and 304 deletions

View File

@ -6,7 +6,6 @@ import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Terminal 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.filled.WifiOff
import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.Lock 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. * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle.
*/ */

View File

@ -2,9 +2,6 @@ package com.bitchat.android.ui
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth 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.Security
import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Speed
import androidx.compose.animation.animateColorAsState 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette 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 * 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 } // end Settings tab
// Footer // Footer
@ -585,7 +531,7 @@ fun AboutSheet(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
if (onShowDebug != null) { if (selectedTab == AboutTab.Settings && onShowDebug != null) {
TextButton(onClick = onShowDebug) { TextButton(onClick = onShowDebug) {
Text( Text(
text = stringResource(R.string.about_debug_settings), text = stringResource(R.string.about_debug_settings),

View File

@ -510,20 +510,26 @@ private fun MainHeader(
} }
} }
// Location Notes button (extracted to separate component) // Location notes + channel badge: one tight unit so the document glyph and the
LocationNotesButton( // bluetooth/globe glyph sit at the same visual pitch as other header pairings.
viewModel = viewModel, Row(
onClick = onLocationNotesClick 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 // 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 // button. Duplicating it here bought a shortcut for a rare action at the cost
// of a slot in the app's most crowded row. // of a slot in the app's most crowded row.
LocationChannelsButton( LocationChannelsButton(
viewModel = viewModel, viewModel = viewModel,
onClick = onLocationChannelsClick onClick = onLocationChannelsClick
) )
}
PeerCounter( PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
@ -574,7 +580,9 @@ private fun LocationChannelsButton(
.clip(HeaderClusterShape) .clip(HeaderClusterShape)
.clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() }
.height(HeaderTapTarget) .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( Icon(
imageVector = badgeIcon, imageVector = badgeIcon,

View File

@ -4,10 +4,26 @@ package com.bitchat.android.ui
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* 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.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing 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.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.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -18,7 +34,11 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip 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.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -174,23 +194,96 @@ class CombinedVisualTransformation(private val transformations: List<VisualTrans
/** Minimum height of the composer pill: a single line of 15.sp text plus 12.dp of padding. */ /**
private val ComposerMinHeight = 44.dp * Minimum height of the composer pill.
*
* Roomy on purpose. The composer is a primary target that gets hit constantly, and the previous
* 44.dp felt cramped once the action buttons moved inside it.
*/
private val ComposerMinHeight = 52.dp
/** /**
* Composer corner radius. * Composer corner radius.
* *
* Fixed rather than "50%": at [ComposerMinHeight] this yields a true capsule, and when the field * Half of [ComposerMinHeight], so a single-line composer is a true capsule. Fixed rather than
* grows to multiple lines it stays a generously rounded rectangle instead of degenerating into * percentage-based so that when the field grows to several lines it stays a generously rounded
* the stadium shape a percentage radius would produce. * rectangle instead of degenerating into a stadium.
*/ */
private val ComposerShape = RoundedCornerShape(22.dp) private val ComposerShape = RoundedCornerShape(ComposerMinHeight / 2)
/** Diameter of the circular send affordance nested inside the pill. */ /** Tap target for every button inside the pill. */
private val SendButtonSize = 36.dp private val ComposerButtonSize = 40.dp
/** Icon size shared by the composer's glyphs, matching the top bar. */ /** Diameter of the visible disc inside that tap target. */
internal val ComposerIconSize = 22.dp private val ComposerButtonDisc = 36.dp
/** Icon size shared by the composer's glyphs. */
internal val ComposerIconSize = 20.dp
/**
* The shared visual treatment for every button in the composer: camera, microphone, send.
*
* One style for all three, so the cluster reads as a set. At rest they are neutral grey discs;
* "active" (send with something to send, microphone while recording) tints towards a soft green
* rather than the terminal's full-brightness primary, which was far too loud sitting right next
* to the text you are typing.
*
* The caller owns the gesture, because the three buttons need very different ones (click,
* long-press for the camera, press-and-hold for the microphone). This composable only supplies
* the geometry, the colours, and the press feedback.
*/
@Composable
internal fun ComposerActionSurface(
isActive: Boolean,
modifier: Modifier = Modifier,
isPressed: Boolean = false,
/** Accent for the active state. Defaults to the soft green used by the microphone and send. */
activeColor: Color = Color.Unspecified,
contentDescription: String? = null,
content: @Composable (tint: Color) -> 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 @Composable
fun MessageInput( fun MessageInput(
@ -206,60 +299,72 @@ fun MessageInput(
showMediaButtons: Boolean, showMediaButtons: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current val palette = LocalBitchatPalette.current
val isFocused = remember { mutableStateOf(false) } val isFocused = remember { mutableStateOf(false) }
val hasText = value.text.isNotBlank() // Check if there's text for send button state val hasText = value.text.isNotBlank()
val keyboard = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var isRecording by remember { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) } var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) } 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( val borderColor by animateColorAsState(
targetValue = when { targetValue = when {
isRecording -> palette.accentRed.copy(alpha = 0.6f) isRecording -> palette.accentRed.copy(alpha = 0.7f)
isFocused.value -> palette.outline isFocused.value -> palette.inputOutlineFocused
else -> palette.outlineVariant else -> palette.inputOutline
}, },
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerBorder" 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( Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp),
// Bottom-aligned so that when the field grows to several lines the send button and the verticalAlignment = Alignment.Bottom
// 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)
) { ) {
// 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( Row(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.heightIn(min = ComposerMinHeight) .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), .border(1.dp, borderColor, ComposerShape),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .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 // Always keep the text field mounted to retain focus and avoid IME collapse
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = onValueChange, 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( textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary, color = palette.textPrimary,
fontFamily = FontFamily.Monospace 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), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { 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. // Cap the growth so a pasted wall of text cannot swallow the message list.
maxLines = 6, maxLines = 6,
@ -285,34 +390,52 @@ fun MessageInput(
} }
) )
// Show placeholder when there's no text and not recording // Placeholder fades rather than blinking, which matters because it reappears
if (value.text.isEmpty() && !isRecording) { // 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(
text = stringResource(R.string.type_a_message_placeholder), text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
), ),
color = palette.textTertiary, 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) { if (isRecording) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(
modifier = Modifier
.fillMaxWidth()
.alpha(waveformAlpha),
verticalAlignment = Alignment.CenterVertically
) {
RealtimeScrollingWaveform( RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(24.dp), modifier = Modifier.weight(1f).height(22.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude) amplitudeNorm = normalizeAmplitudeSample(amplitude)
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
val secs = (elapsedMs / 1000).toInt() val secs = (elapsedMs / 1000).toInt()
val mm = secs / 60
val ss = secs % 60
val maxSecs = 10 // 10 second max recording time val maxSecs = 10 // 10 second max recording time
val maxMm = maxSecs / 60
val maxSs = maxSecs % 60
Text( 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, fontFamily = FontFamily.Monospace,
color = palette.accentRed, color = palette.accentRed,
fontSize = (BASE_FONT_SIZE - 4).sp fontSize = (BASE_FONT_SIZE - 4).sp
@ -321,60 +444,116 @@ fun MessageInput(
} }
} }
SendButton( // MARK: - Action cluster, inside the pill.
hasText = hasText, //
isAccented = selectedPrivatePeer != null || currentChannel != null, // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and
onSend = onSend, // scales between the two, and SizeTransform animates the width change, so typing the
modifier = Modifier.padding(end = 4.dp, bottom = 4.dp) // first character morphs camera+mic into send instead of snapping.
)
}
// 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
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
val latestChannel = rememberUpdatedState(currentChannel) val latestChannel = rememberUpdatedState(currentChannel)
val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote) val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote)
// Image button (image picker) - hide during recording AnimatedContent(
if (!isRecording) { targetState = hasText,
ImagePickerButton( transitionSpec = {
onImageReady = { outPath -> (
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) 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( VoiceRecordButton(
backgroundColor = colorScheme.primary, isRecording = isRecording,
onStart = { onStart = {
isRecording = true isRecording = true
elapsedMs = 0L elapsedMs = 0L
// Keep existing focus to avoid IME collapse, but do not force-show keyboard // Keep existing focus to avoid IME collapse, but do not
if (isFocused.value) { // force-show the keyboard.
try { focusRequester.requestFocus() } catch (_: Exception) {} if (isFocused.value) {
} try { focusRequester.requestFocus() } catch (_: Exception) {}
}, }
onAmplitude = { amp, ms -> },
amplitude = amp onAmplitude = { amp, ms ->
elapsedMs = ms amplitude = amp
}, elapsedMs = ms
onFinish = { path -> },
isRecording = false onFinish = { path ->
// Extract and cache waveform from the actual audio file to match receiver rendering isRecording = false
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> // Extract and cache the waveform from the actual audio file
if (arr != null) { // so it matches the receiver's rendering.
try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {} 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. * 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.
* 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.
*/ */
@Composable @Composable
private fun SendButton( private fun SendButton(
hasText: Boolean,
isAccented: Boolean, isAccented: Boolean,
onSend: () -> Unit, onSend: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
enabled: Boolean = true
) { ) {
val palette = LocalBitchatPalette.current val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val targetBackground = when { ComposerActionSurface(
!hasText -> palette.outline isActive = enabled,
isAccented -> palette.accentOrange isPressed = isPressed,
else -> colorScheme.primary // Private chats and channels keep their orange identity, disc and glyph together.
} activeColor = if (isAccented) palette.accentOrange else palette.accentGreen,
val targetTint = when { modifier = modifier.clickable(
!hasText -> palette.textTertiary interactionSource = interactionSource,
// Both accents are bright enough that black is the only legible arrow colour. indication = null,
else -> Color.Black enabled = enabled
} ) { onSend() }
) { tint ->
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
) {
Icon( Icon(
imageVector = Icons.Filled.ArrowUpward, imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(id = R.string.send_message), contentDescription = stringResource(id = R.string.send_message),

View File

@ -2,16 +2,19 @@ package com.bitchat.android.ui
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Description 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.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -53,13 +56,19 @@ fun LocationNotesButton(
// Only show in mesh mode when location is authorized (iOS pattern) // Only show in mesh mode when location is authorized (iOS pattern)
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
val hasNotes = notesCount > 0 val hasNotes = notesCount > 0
IconButton( val contentDescription = stringResource(R.string.cd_location_notes)
onClick = onClick, // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding
modifier = modifier.size(44.dp) // 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( Icon(
imageVector = Icons.Outlined.Description, imageVector = Icons.Outlined.Description,
contentDescription = stringResource(R.string.cd_location_notes), contentDescription = contentDescription,
modifier = Modifier.size(HeaderIconSize), modifier = Modifier.size(HeaderIconSize),
tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary
) )

View File

@ -3,22 +3,16 @@ package com.bitchat.android.ui
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import android.Manifest import android.Manifest
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.voice.VoiceRecorder import com.bitchat.android.features.voice.VoiceRecorder
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.PermissionStatus
@ -32,7 +26,11 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun VoiceRecordButton( fun VoiceRecordButton(
modifier: Modifier = Modifier, 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, onStart: () -> Unit,
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
onFinish: (filePath: String) -> Unit onFinish: (filePath: String) -> Unit
@ -41,7 +39,7 @@ fun VoiceRecordButton(
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
var isRecording by remember { mutableStateOf(false) } var isCapturing by remember { mutableStateOf(false) }
var recorder by remember { mutableStateOf<VoiceRecorder?>(null) } var recorder by remember { mutableStateOf<VoiceRecorder?>(null) }
var recordedFilePath by remember { mutableStateOf<String?>(null) } var recordedFilePath by remember { mutableStateOf<String?>(null) }
var recordingStart by remember { mutableStateOf(0L) } var recordingStart by remember { mutableStateOf(0L) }
@ -54,14 +52,15 @@ fun VoiceRecordButton(
val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnAmplitude = rememberUpdatedState(onAmplitude)
val latestOnFinish = rememberUpdatedState(onFinish) 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 modifier = modifier
.size(36.dp)
.background(backgroundColor, CircleShape)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
if (!isRecording) { if (!isCapturing) {
if (micPermission.status !is PermissionStatus.Granted) { if (micPermission.status !is PermissionStatus.Granted) {
micPermission.launchPermissionRequest() micPermission.launchPermissionRequest()
return@detectTapGestures return@detectTapGestures
@ -69,24 +68,24 @@ fun VoiceRecordButton(
val rec = VoiceRecorder(context) val rec = VoiceRecorder(context)
val f = rec.start() val f = rec.start()
recorder = rec recorder = rec
isRecording = f != null isCapturing = f != null
recordedFilePath = f?.absolutePath recordedFilePath = f?.absolutePath
recordingStart = System.currentTimeMillis() recordingStart = System.currentTimeMillis()
if (isRecording) { if (isCapturing) {
latestOnStart.value() latestOnStart.value()
// Haptic "knock" when recording starts // Haptic "knock" when recording starts
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
// Start amplitude polling loop // Start amplitude polling loop
ampJob?.cancel() ampJob?.cancel()
ampJob = scope.launch { ampJob = scope.launch {
while (isActive && isRecording) { while (isActive && isCapturing) {
val amp = recorder?.pollAmplitude() ?: 0 val amp = recorder?.pollAmplitude() ?: 0
val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
latestOnAmplitude.value(amp, elapsedMs) latestOnAmplitude.value(amp, elapsedMs)
// Auto-stop after 10 seconds // Auto-stop after 10 seconds
if (elapsedMs >= 10_000 && isRecording) { if (elapsedMs >= 10_000 && isCapturing) {
val file = recorder?.stop() val file = recorder?.stop()
isRecording = false isCapturing = false
recorder = null recorder = null
val path = file?.absolutePath val path = file?.absolutePath
if (!path.isNullOrBlank()) { if (!path.isNullOrBlank()) {
@ -104,13 +103,13 @@ fun VoiceRecordButton(
try { try {
awaitRelease() awaitRelease()
} finally { } finally {
if (isRecording) { if (isCapturing) {
// Extend recording for 500ms after release to avoid clipping // Extend recording for 500ms after release to avoid clipping
delay(500) delay(500)
} }
if (isRecording) { if (isCapturing) {
val file = recorder?.stop() val file = recorder?.stop()
isRecording = false isCapturing = false
recorder = null recorder = null
val path = (file?.absolutePath ?: recordedFilePath) val path = (file?.absolutePath ?: recordedFilePath)
recordedFilePath = null recordedFilePath = null
@ -125,14 +124,13 @@ fun VoiceRecordButton(
} }
} }
) )
}, }
contentAlignment = Alignment.Center ) { tint ->
) {
Icon( Icon(
imageVector = Icons.Filled.Mic, imageVector = Icons.Filled.Mic,
contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice),
tint = Color.Black, tint = tint,
modifier = Modifier.size(22.dp) modifier = Modifier.size(ComposerIconSize)
) )
} }
} }

View File

@ -11,22 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable 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.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils 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 import java.io.File
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -89,28 +87,31 @@ fun ImagePickerButton(
} }
} }
Box( val interactionSource = remember { MutableInteractionSource() }
modifier = modifier val isPressed by interactionSource.collectIsPressedAsState()
// Matches the header's tap targets and the composer's send button.
.size(44.dp) // Shares the composer's button treatment so camera, microphone and send read as one set.
.clip(CircleShape) ComposerActionSurface(
.combinedClickable( isActive = false,
onClick = { imagePicker.launch("image/*") }, isPressed = isPressed,
onLongClick = { modifier = modifier.combinedClickable(
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { interactionSource = interactionSource,
startCameraCapture() indication = null,
} else { onClick = { imagePicker.launch("image/*") },
permissionLauncher.launch(Manifest.permission.CAMERA) onLongClick = {
} if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startCameraCapture()
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
} }
), }
contentAlignment = Alignment.Center )
) { ) { tint ->
Icon( Icon(
imageVector = Icons.Filled.PhotoCamera, imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(com.bitchat.android.R.string.pick_image), contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
tint = LocalBitchatPalette.current.textSecondary, tint = tint,
modifier = Modifier.size(22.dp) modifier = Modifier.size(ComposerIconSize)
) )
} }

View File

@ -33,6 +33,17 @@ data class BitchatPalette(
/** Hairline dividers. */ /** Hairline dividers. */
val outlineVariant: Color, 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 // MARK: - Text
/** Message bodies and row titles. Neutral, not green. */ /** Message bodies and row titles. Neutral, not green. */
val textPrimary: Color, val textPrimary: Color,
@ -64,6 +75,8 @@ val DarkBitchatPalette = BitchatPalette(
surfaceVariant = Color(0xFF182118), surfaceVariant = Color(0xFF182118),
outline = Color(0xFF2A3A2A), outline = Color(0xFF2A3A2A),
outlineVariant = Color(0xFF1C271C), outlineVariant = Color(0xFF1C271C),
inputOutline = Color(0xFF333635),
inputOutlineFocused = Color(0xFF5A605D),
textPrimary = Color(0xFFE8EDE8), textPrimary = Color(0xFFE8EDE8),
textSecondary = Color(0xFF9AA69A), textSecondary = Color(0xFF9AA69A),
textTertiary = Color(0xFF6B776B), textTertiary = Color(0xFF6B776B),
@ -81,6 +94,8 @@ val LightBitchatPalette = BitchatPalette(
surfaceVariant = Color(0xFFE7EDE7), surfaceVariant = Color(0xFFE7EDE7),
outline = Color(0xFFCBD6CB), outline = Color(0xFFCBD6CB),
outlineVariant = Color(0xFFDEE6DE), outlineVariant = Color(0xFFDEE6DE),
inputOutline = Color(0xFFCFD3D1),
inputOutlineFocused = Color(0xFF8E9490),
textPrimary = Color(0xFF131A13), textPrimary = Color(0xFF131A13),
textSecondary = Color(0xFF4C574C), textSecondary = Color(0xFF4C574C),
textTertiary = Color(0xFF757F75), textTertiary = Color(0xFF757F75),

View File

@ -135,10 +135,6 @@
<string name="about_ephemeral_desc">A new peer ID is generated regularly.</string> <string name="about_ephemeral_desc">A new peer ID is generated regularly.</string>
<string name="about_panic_desc">Triple-tap the logo to instantly clear all data.</string> <string name="about_panic_desc">Triple-tap the logo to instantly clear all data.</string>
<!-- About sheet: security warning -->
<string name="about_warning_title">Warning</string>
<string name="about_warning_body">Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears.</string>
<!-- About sheet: settings labels --> <!-- About sheet: settings labels -->
<string name="about_tor_title">Tor Network</string> <string name="about_tor_title">Tor Network</string>
<string name="about_difficulty">Difficulty</string> <string name="about_difficulty">Difficulty</string>