This commit is contained in:
callebtc 2026-07-27 03:26:15 +02:00
parent 893e7d3875
commit 02a737fad6
8 changed files with 322 additions and 188 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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