mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
input bar fixes
This commit is contained in:
parent
53a891a71f
commit
893e7d3875
@ -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.
|
||||
*/
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user