mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +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.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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
@ -34,7 +37,7 @@ fun LocationNotesButton(
|
|||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Get channel and permission state
|
// Get channel and permission state
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||||
@ -44,7 +47,7 @@ fun LocationNotesButton(
|
|||||||
// Check both permission AND location services enabled
|
// Check both permission AND location services enabled
|
||||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||||
val locationEnabled = locationPermissionGranted && locationServicesEnabled
|
val locationEnabled = locationPermissionGranted && locationServicesEnabled
|
||||||
|
|
||||||
// Get notes count from LocationNotesManager
|
// Get notes count from LocationNotesManager
|
||||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user