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.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.
*/

View File

@ -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),

View File

@ -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,

View File

@ -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<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.
*
* Fixed rather than "50%": at [ComposerMinHeight] this yields a true capsule, and when the field
* grows to multiple lines it stays a generously rounded rectangle instead of degenerating into
* the stadium shape a percentage radius would produce.
* Half of [ComposerMinHeight], so a single-line composer is a true capsule. Fixed rather than
* percentage-based so that when the field grows to several lines it stays a generously rounded
* 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. */
private val SendButtonSize = 36.dp
/** Tap target for every button inside the pill. */
private val ComposerButtonSize = 40.dp
/** Icon size shared by the composer's glyphs, matching the top bar. */
internal val ComposerIconSize = 22.dp
/** Diameter of the visible disc inside that tap target. */
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
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),

View File

@ -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
)

View File

@ -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<VoiceRecorder?>(null) }
var recordedFilePath by remember { mutableStateOf<String?>(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)
)
}
}

View File

@ -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)
)
}

View File

@ -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),

View File

@ -135,10 +135,6 @@
<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>
<!-- 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 -->
<string name="about_tor_title">Tor Network</string>
<string name="about_difficulty">Difficulty</string>