mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
fixes
This commit is contained in:
parent
893e7d3875
commit
02a737fad6
@ -4,7 +4,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AlternateEmail
|
||||
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.WifiOff
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
@ -339,71 +338,40 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The six-item capability list from the design.
|
||||
*
|
||||
* Icons are Material approximations of the designer's custom line art; swap in the exported
|
||||
* SVGs when they are available.
|
||||
* Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutFeatureCard(modifier: Modifier = Modifier) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val features = listOf(
|
||||
Triple(
|
||||
Icons.Filled.WifiOff,
|
||||
R.string.about_offline_mesh_title,
|
||||
R.string.about_offline_mesh_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.Lock,
|
||||
R.string.about_e2e_title,
|
||||
R.string.about_e2e_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.Public,
|
||||
R.string.about_online_geohash_title,
|
||||
R.string.about_online_geohash_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.VisibilityOff,
|
||||
R.string.about_no_tracking_title,
|
||||
R.string.about_no_tracking_desc
|
||||
),
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = palette.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
val features = listOf(
|
||||
Triple(
|
||||
Icons.Filled.WifiOff,
|
||||
R.string.about_offline_mesh_title,
|
||||
R.string.about_offline_mesh_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.Lock,
|
||||
R.string.about_e2e_title,
|
||||
R.string.about_e2e_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.Public,
|
||||
R.string.about_online_geohash_title,
|
||||
R.string.about_online_geohash_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Outlined.VisibilityOff,
|
||||
R.string.about_no_tracking_title,
|
||||
R.string.about_no_tracking_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Filled.Shuffle,
|
||||
R.string.about_ephemeral_title,
|
||||
R.string.about_ephemeral_desc
|
||||
),
|
||||
Triple(
|
||||
Icons.Filled.DeleteForever,
|
||||
R.string.about_emergency_title,
|
||||
R.string.about_panic_desc
|
||||
),
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
features.forEach { (icon, titleRes, descRes) ->
|
||||
AboutFeatureRow(
|
||||
icon = icon,
|
||||
title = stringResource(titleRes),
|
||||
subtitle = stringResource(descRes)
|
||||
)
|
||||
|
||||
features.forEachIndexed { index, (icon, titleRes, descRes) ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
// Inset to align with the text column, not the icon.
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = palette.outlineVariant
|
||||
)
|
||||
}
|
||||
AboutFeatureRow(
|
||||
icon = icon,
|
||||
title = stringResource(titleRes),
|
||||
subtitle = stringResource(descRes)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -420,7 +388,8 @@ private fun AboutFeatureRow(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
@ -429,15 +398,15 @@ private fun AboutFeatureRow(
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(24.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,
|
||||
lineHeight = 20.sp,
|
||||
color = palette.textPrimary
|
||||
)
|
||||
Text(
|
||||
|
||||
@ -560,7 +560,6 @@ private fun LocationChannelsButton(
|
||||
|
||||
// Get current channel selection from location manager
|
||||
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
|
||||
val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location
|
||||
val badgeText = when (val channel = selectedChannel) {
|
||||
@ -599,15 +598,5 @@ private fun LocationChannelsButton(
|
||||
color = badgeColor,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
// Teleportation indicator (like iOS)
|
||||
if (teleported) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PinDrop,
|
||||
contentDescription = stringResource(R.string.cd_teleported),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = badgeColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,11 +14,14 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@ -120,7 +123,14 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.background(colorScheme.background) // Extend background to fill entire screen including status bar
|
||||
) {
|
||||
val headerHeight = ChatHeaderHeight
|
||||
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
// Both bars are translucent and the conversation scrolls underneath them, so their
|
||||
// heights are reserved as list padding instead of as layout space. The composer's height
|
||||
// varies (suggestion rows, wrapped lines), so it is measured rather than assumed.
|
||||
var composerHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
// Main content area that responds to keyboard/window insets
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -128,19 +138,17 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets
|
||||
.windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded
|
||||
) {
|
||||
// Header spacer - creates exact space for the floating header (status bar + compact header)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.height(headerHeight)
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Messages area - takes up available space, will compress when keyboard appears
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = statusBarHeight + headerHeight,
|
||||
bottom = composerHeight
|
||||
),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { fullSenderName ->
|
||||
@ -188,7 +196,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
showFullScreenImageViewer = true
|
||||
}
|
||||
)
|
||||
// Input area - stays at bottom
|
||||
// Input area - overlays the bottom of the conversation
|
||||
// Bridge file share from lower-level input to ViewModel
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path ->
|
||||
@ -197,6 +205,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
|
||||
ChatInputSection(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.onSizeChanged { size ->
|
||||
composerHeight = with(density) { size.height.toDp() }
|
||||
},
|
||||
messageText = messageText,
|
||||
onMessageTextChange = { newText: TextFieldValue ->
|
||||
messageText = newText
|
||||
@ -244,6 +257,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = showMediaButtons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating header - positioned absolutely at top, ignores keyboard
|
||||
@ -261,17 +275,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
onLocationNotesClick = { showLocationNotesSheet = true }
|
||||
)
|
||||
|
||||
// Divider under header - positioned after status bar + header height
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.offset(y = headerHeight)
|
||||
.zIndex(1f),
|
||||
thickness = 1.dp,
|
||||
color = palette.outlineVariant
|
||||
)
|
||||
|
||||
// Scroll-to-bottom floating button
|
||||
AnimatedVisibility(
|
||||
visible = isScrolledUp,
|
||||
@ -287,7 +290,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 64.dp)
|
||||
.padding(end = 16.dp, bottom = composerHeight + 8.dp)
|
||||
.zIndex(1.5f)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
@ -407,50 +410,73 @@ fun ChatInputSection(
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
showMediaButtons: Boolean
|
||||
showMediaButtons: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.background
|
||||
|
||||
Column(
|
||||
// Flat, slightly translucent screen background — the same treatment as the top bar, so the
|
||||
// two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked
|
||||
// like a smudge above a crisp hairline. The rule is inside the background so the whole bar
|
||||
// is one surface with a top border, rather than a line floating over the conversation.
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.background.copy(alpha = BarBackgroundAlpha))
|
||||
) {
|
||||
// No divider above the composer: the pill's own border provides the separation, and a
|
||||
// full-width rule on top of it read as a double line.
|
||||
Column {
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
CommandSuggestionsBox(
|
||||
suggestions = commandSuggestions,
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
}
|
||||
// Mention suggestions box
|
||||
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
|
||||
MentionSuggestionsBox(
|
||||
suggestions = mentionSuggestions,
|
||||
onSuggestionClick = onMentionSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
}
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
onValueChange = onMessageTextChange,
|
||||
onSend = onSend,
|
||||
onSendVoiceNote = onSendVoiceNote,
|
||||
onSendImageNote = onSendImageNote,
|
||||
onSendFileNote = onSendFileNote,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
// Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border.
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
CommandSuggestionsBox(
|
||||
suggestions = commandSuggestions,
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
}
|
||||
// Mention suggestions box
|
||||
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
|
||||
MentionSuggestionsBox(
|
||||
suggestions = mentionSuggestions,
|
||||
onSuggestionClick = onMentionSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
}
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
onValueChange = onMessageTextChange,
|
||||
onSend = onSend,
|
||||
onSendVoiceNote = onSendVoiceNote,
|
||||
onSendImageNote = onSendImageNote,
|
||||
onSendFileNote = onSendFileNote,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opacity shared by both bars.
|
||||
*
|
||||
* Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads
|
||||
* as sitting over the content rather than boxing it in — without ever costing legibility.
|
||||
*/
|
||||
private const val BarBackgroundAlpha = 0.88f
|
||||
|
||||
/**
|
||||
* Fraction of the header that stays fully opaque, measured from the top.
|
||||
*
|
||||
* The header is the one place a gradient earns its keep: the status bar is transparent, so the
|
||||
* header has to be the true background colour where the two meet or the system bar stops looking
|
||||
* like part of the app. Everything below that stop matches the composer's flat translucency.
|
||||
*/
|
||||
private const val HeaderOpaqueStop = 0.72f
|
||||
@Composable
|
||||
private fun ChatFloatingHeader(
|
||||
headerHeight: Dp,
|
||||
@ -467,13 +493,24 @@ private fun ChatFloatingHeader(
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||
|
||||
Surface(
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
.windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area
|
||||
color = colorScheme.background // Solid background color extending into status bar
|
||||
// Fully opaque where it meets the system status bar, fading to translucent at its
|
||||
// lower edge. The status bar itself is transparent, so anything less than opaque at
|
||||
// the top would let the wallpaper or a light system-bar scrim bleed through and the
|
||||
// header would stop reading as part of the app.
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to colorScheme.background,
|
||||
HeaderOpaqueStop to colorScheme.background,
|
||||
1f to colorScheme.background.copy(alpha = BarBackgroundAlpha)
|
||||
)
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area
|
||||
) {
|
||||
// A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal
|
||||
// pad plus a 12.dp title inset and applies its own minimum heights, which made the
|
||||
|
||||
@ -220,6 +220,15 @@ private val ComposerButtonDisc = 36.dp
|
||||
/** Icon size shared by the composer's glyphs. */
|
||||
internal val ComposerIconSize = 20.dp
|
||||
|
||||
/**
|
||||
* Opacity of the composer pill.
|
||||
*
|
||||
* Not fully opaque: the message list scrolls underneath the composer, and letting a hint of it
|
||||
* through is what makes the bar read as sitting *over* the conversation rather than boxing it in.
|
||||
* Kept high enough that text in the field never loses contrast.
|
||||
*/
|
||||
private const val ComposerFillAlpha = 0.88f
|
||||
|
||||
/**
|
||||
* The shared visual treatment for every button in the composer: camera, microphone, send.
|
||||
*
|
||||
@ -248,7 +257,7 @@ internal fun ComposerActionSurface(
|
||||
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,
|
||||
targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "composerButtonContainer"
|
||||
)
|
||||
@ -318,9 +327,10 @@ fun MessageInput(
|
||||
label = "composerBorder"
|
||||
)
|
||||
// A barely-there lift on focus. Enough to register, not enough to look like a different
|
||||
// component.
|
||||
// component. Slightly translucent so the messages scrolling underneath stay faintly visible.
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (isFocused.value) palette.surfaceVariant else palette.surface,
|
||||
targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
|
||||
.copy(alpha = ComposerFillAlpha),
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "composerContainer"
|
||||
)
|
||||
@ -544,6 +554,14 @@ fun MessageInput(
|
||||
latestChannel.value,
|
||||
path
|
||||
)
|
||||
},
|
||||
// Any capture that ends without a file must clear the recording
|
||||
// state here too, otherwise the pill stays red with a live
|
||||
// waveform over an empty field.
|
||||
onCancel = {
|
||||
isRecording = false
|
||||
amplitude = 0
|
||||
elapsedMs = 0L
|
||||
}
|
||||
)
|
||||
} else {
|
||||
|
||||
@ -860,6 +860,7 @@ fun PrivateChatSheet(
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
@ -872,7 +873,7 @@ fun PrivateChatSheet(
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
|
||||
|
||||
// Messages list
|
||||
var forceScrollToBottom by remember { mutableStateOf(false) }
|
||||
@ -891,9 +892,8 @@ fun PrivateChatSheet(
|
||||
onImageClick = { _, _, _ -> /* handle image click */ }
|
||||
)
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
|
||||
// Input section
|
||||
// Input section. No divider here: ChatInputSection draws its own fade and
|
||||
// hairline.
|
||||
var messageText by remember {
|
||||
mutableStateOf(
|
||||
androidx.compose.ui.text.input.TextFieldValue(
|
||||
|
||||
@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -37,6 +39,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@ -72,6 +75,13 @@ fun MessagesList(
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Extra inset on top of the list's own gutters.
|
||||
*
|
||||
* The chat screen's bars are translucent and the list scrolls underneath them, so the caller
|
||||
* has to reserve room for their heights here rather than by shrinking the viewport.
|
||||
*/
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
forceScrollToBottom: Boolean = false,
|
||||
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
@ -119,11 +129,17 @@ fun MessagesList(
|
||||
}
|
||||
}
|
||||
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
// Wider side gutters than the old 12.dp: the redesign trades a little line length for
|
||||
// a much calmer edge, and long monospace lines were running into the screen bezel.
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp + contentPadding.calculateStartPadding(layoutDirection),
|
||||
end = 16.dp + contentPadding.calculateEndPadding(layoutDirection),
|
||||
top = 8.dp + contentPadding.calculateTopPadding(),
|
||||
bottom = 12.dp + contentPadding.calculateBottomPadding()
|
||||
),
|
||||
// Spacing is owned by each item so that a continuation of the same author can sit
|
||||
// tighter than the start of a new speaker's run.
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
|
||||
@ -21,6 +21,34 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* How long the button must be held before a recording starts.
|
||||
*
|
||||
* Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a
|
||||
* recording, and it also made the control impossible to defend against pointer events it should
|
||||
* never have seen (see [ArmDelayMs]).
|
||||
*/
|
||||
private const val HoldToRecordMs = 220L
|
||||
|
||||
/**
|
||||
* How long the button ignores presses after entering composition.
|
||||
*
|
||||
* The action cluster swaps send out for camera+microphone the instant the field is cleared, which
|
||||
* puts the microphone exactly where the send button was a frame earlier. Tapping send quickly
|
||||
* could hand the microphone a pointer-down whose matching pointer-up had already been delivered
|
||||
* to the send button that no longer exists — leaving the gesture waiting for a release that will
|
||||
* never come, stuck in "recording" until the composable is disposed. Refusing presses until the
|
||||
* swap animation has settled removes that whole class of failure.
|
||||
*/
|
||||
private const val ArmDelayMs = 350L
|
||||
|
||||
/** Hard cap on a single recording. */
|
||||
private const val MaxRecordingMs = 10_000L
|
||||
|
||||
/** Tail kept after release so the last syllable is not clipped. */
|
||||
private const val ReleaseTailMs = 500L
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
@ -33,7 +61,13 @@ fun VoiceRecordButton(
|
||||
isRecording: Boolean = false,
|
||||
onStart: () -> Unit,
|
||||
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
|
||||
onFinish: (filePath: String) -> Unit
|
||||
onFinish: (filePath: String) -> Unit,
|
||||
/**
|
||||
* Invoked whenever a recording ends without producing a file — permission denied, recorder
|
||||
* failure, or the button being torn down mid-capture. The caller needs this to clear its own
|
||||
* recording state; without it a failed capture left the composer stuck in recording mode.
|
||||
*/
|
||||
onCancel: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
@ -51,6 +85,35 @@ fun VoiceRecordButton(
|
||||
val latestOnStart = rememberUpdatedState(onStart)
|
||||
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
|
||||
val latestOnFinish = rememberUpdatedState(onFinish)
|
||||
val latestOnCancel = rememberUpdatedState(onCancel)
|
||||
|
||||
// Set when this instance was composed, so presses inherited from whatever occupied this spot
|
||||
// beforehand can be rejected.
|
||||
val composedAt = remember { System.currentTimeMillis() }
|
||||
|
||||
fun buzz() {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
// Last line of defence: if the button is removed while capturing — the cluster swapping to
|
||||
// send, the sheet closing, the screen going away — release the recorder and tell the caller,
|
||||
// so nothing is left holding the microphone or showing a recording UI.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
ampJob?.cancel()
|
||||
ampJob = null
|
||||
if (isCapturing) {
|
||||
isCapturing = false
|
||||
runCatching { recorder?.stop() }
|
||||
recorder = null
|
||||
recordedFilePath = null
|
||||
latestOnCancel.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same disc, same sizing and the same press feedback as the camera and send buttons.
|
||||
ComposerActionSurface(
|
||||
@ -60,63 +123,89 @@ fun VoiceRecordButton(
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (!isCapturing) {
|
||||
if (micPermission.status !is PermissionStatus.Granted) {
|
||||
micPermission.launchPermissionRequest()
|
||||
return@detectTapGestures
|
||||
}
|
||||
val rec = VoiceRecorder(context)
|
||||
val f = rec.start()
|
||||
recorder = rec
|
||||
isCapturing = f != null
|
||||
recordedFilePath = f?.absolutePath
|
||||
recordingStart = System.currentTimeMillis()
|
||||
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 && 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 && isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath
|
||||
if (!path.isNullOrBlank()) {
|
||||
// Haptic "knock" on auto stop
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
latestOnFinish.value(path)
|
||||
}
|
||||
break
|
||||
}
|
||||
delay(80)
|
||||
// Guard 1: ignore anything arriving before the swap animation settled.
|
||||
if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
|
||||
return@detectTapGestures
|
||||
}
|
||||
// Guard 2: never start a second capture on top of a live one.
|
||||
if (isCapturing) return@detectTapGestures
|
||||
|
||||
if (micPermission.status !is PermissionStatus.Granted) {
|
||||
micPermission.launchPermissionRequest()
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
// Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on
|
||||
// release and false on cancellation; either way the press was not a hold,
|
||||
// so nothing should happen. Only a timeout means the finger is still down.
|
||||
val stillHeld = withTimeoutOrNull(HoldToRecordMs) {
|
||||
tryAwaitRelease()
|
||||
} == null
|
||||
if (!stillHeld) return@detectTapGestures
|
||||
|
||||
val rec = VoiceRecorder(context)
|
||||
val startedFile = rec.start()
|
||||
if (startedFile == null) {
|
||||
// Recorder refused to start; make sure the caller does not sit in a
|
||||
// recording state that never began.
|
||||
runCatching { rec.stop() }
|
||||
latestOnCancel.value()
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
recorder = rec
|
||||
recordedFilePath = startedFile.absolutePath
|
||||
recordingStart = System.currentTimeMillis()
|
||||
isCapturing = true
|
||||
latestOnStart.value()
|
||||
buzz()
|
||||
|
||||
ampJob?.cancel()
|
||||
ampJob = scope.launch {
|
||||
while (isActive && isCapturing) {
|
||||
val amp = recorder?.pollAmplitude() ?: 0
|
||||
val elapsed =
|
||||
(System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
|
||||
latestOnAmplitude.value(amp, elapsed)
|
||||
|
||||
if (elapsed >= MaxRecordingMs && isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath ?: recordedFilePath
|
||||
recordedFilePath = null
|
||||
buzz()
|
||||
// Always report the outcome, even when the file is unusable,
|
||||
// or the caller stays stuck showing the waveform.
|
||||
if (!path.isNullOrBlank()) {
|
||||
latestOnFinish.value(path)
|
||||
} else {
|
||||
latestOnCancel.value()
|
||||
}
|
||||
break
|
||||
}
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
awaitRelease()
|
||||
tryAwaitRelease()
|
||||
} finally {
|
||||
if (isCapturing) {
|
||||
// Extend recording for 500ms after release to avoid clipping
|
||||
delay(500)
|
||||
// Keep going briefly past the release so the tail is not clipped.
|
||||
delay(ReleaseTailMs)
|
||||
}
|
||||
if (isCapturing) {
|
||||
val file = recorder?.stop()
|
||||
isCapturing = false
|
||||
recorder = null
|
||||
val path = (file?.absolutePath ?: recordedFilePath)
|
||||
val path = file?.absolutePath ?: recordedFilePath
|
||||
recordedFilePath = null
|
||||
buzz()
|
||||
if (!path.isNullOrBlank()) {
|
||||
// Haptic "knock" when recording stops
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
latestOnFinish.value(path)
|
||||
} else {
|
||||
latestOnCancel.value()
|
||||
}
|
||||
}
|
||||
ampJob?.cancel()
|
||||
|
||||
@ -43,6 +43,16 @@ data class BitchatPalette(
|
||||
val inputOutline: Color,
|
||||
/** Border for a focused text input. A step brighter, still neutral. */
|
||||
val inputOutlineFocused: Color,
|
||||
/**
|
||||
* Fill for text inputs. Near-black / near-white and completely untinted, for the same reason
|
||||
* as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any
|
||||
* tint of its own compounds into something muddy.
|
||||
*/
|
||||
val inputSurface: Color,
|
||||
/** Fill for a focused text input. A barely perceptible lift. */
|
||||
val inputSurfaceFocused: Color,
|
||||
/** Resting disc behind the composer's action glyphs. Neutral grey. */
|
||||
val inputButton: Color,
|
||||
|
||||
// MARK: - Text
|
||||
/** Message bodies and row titles. Neutral, not green. */
|
||||
@ -77,6 +87,9 @@ val DarkBitchatPalette = BitchatPalette(
|
||||
outlineVariant = Color(0xFF1C271C),
|
||||
inputOutline = Color(0xFF333635),
|
||||
inputOutlineFocused = Color(0xFF5A605D),
|
||||
inputSurface = Color(0xFF0B0B0B),
|
||||
inputSurfaceFocused = Color(0xFF151515),
|
||||
inputButton = Color(0xFF1E1E1E),
|
||||
textPrimary = Color(0xFFE8EDE8),
|
||||
textSecondary = Color(0xFF9AA69A),
|
||||
textTertiary = Color(0xFF6B776B),
|
||||
@ -96,6 +109,9 @@ val LightBitchatPalette = BitchatPalette(
|
||||
outlineVariant = Color(0xFFDEE6DE),
|
||||
inputOutline = Color(0xFFCFD3D1),
|
||||
inputOutlineFocused = Color(0xFF8E9490),
|
||||
inputSurface = Color(0xFFFAFAFA),
|
||||
inputSurfaceFocused = Color(0xFFF2F2F2),
|
||||
inputButton = Color(0xFFE8E8E8),
|
||||
textPrimary = Color(0xFF131A13),
|
||||
textSecondary = Color(0xFF4C574C),
|
||||
textTertiary = Color(0xFF757F75),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user