callebtc 7025009788
ui: complete the redesign — palette, top bar, composer, About, and a motion pass (#774)
* first pass

* pass 2

* cleanup

* capitalization

* strings

* input bar fixes

* fixes

* notes

* nice

* nicer

* lists

* cleanup

* button

* fixes

* animations

* Fix layout jumpiness in chat and geohash people list

Three separate causes of things moving when they should not:

- Chat lurched whenever a bottom sheet closed. Placement animation is meant
  to soften insertions and removals, but any relayout moves every item --
  a sheet's text field opening the keyboard changes the chat's IME inset,
  and closing it changes it back. Placement animation is now armed only
  briefly around a real change to the message list, so items otherwise
  track the viewport exactly.

- Anon list changed height as participants churned. Rows sized to their
  content, so any reorder could change the card's height; and the card
  sized to the live anon count, which moves constantly in a busy geohash.
  Rows now have an exact height, and a trimmed anon card reserves the full
  capped height regardless of how many are present beyond the cap.

- Anons are now their own trailing section rather than a tail on each of
  "on location" and "teleported in", which had pushed the few recognisable
  names out of view twice over. Self is never grouped as an anon.

Adds 7 tests covering the sectioning and the fixed-length behaviour.

* Group geohash people as People and Anon

Replaces the "on location" / "teleported in" / "anonymous" split with two
sections: peers who announced a nickname, then the anons.

Teleport state was never worth a section of its own -- every row already
carries it as a distinct glyph -- and splitting on it fragmented the short
list people actually read, in a channel where most participants are
anonymous anyway.

Self stays in the People section even when unnamed.

* Key message list state per conversation

Switching channels reused every piece of state in MessagesList, because none
of it was keyed on which conversation was being shown:

- The LazyListState carried the previous channel's scroll offset, so the new
  channel opened at a stale position and then corrected itself.
- hasScrolledToInitialPosition and followIncomingMessages carried over, so a
  channel entered after scrolling up in another one did not land on its
  newest message at all.
- The arrival tracker had never seen the incoming channel's ids, so a
  backlog of six or fewer messages was treated as six simultaneous arrivals
  and each one slid in.
- previousMessageCount carried over, arming placement animation for the
  relayout that the switch itself caused.

All of it is now keyed on a conversationKey derived the same way
displayMessages is. The tracker also detects a list sharing no ids with the
previous one and adopts it silently, which covers /clear and any caller that
does not supply a distinct key.

Adds 4 tests for wholesale replacement, including the case that the burst
cap cannot catch on its own.

* fix location channel layout

* icon

* location sheet

* move location error

* fix location channel lifecycle bug

* remove empty lable

* geist mono

* timestamp no seconds

* new icons

* icons

* cleanup

* mentions

* fix mentions

* grouping of geohash channel list

* colors

* fix mention colors

* Bring private and group chat headers up to the main header's layout

Both conversation headers were built on TopAppBar with a centred title, a
back arrow on the left and everything else crowded into the title slot, at
14sp with 14dp icons. Moving between the timeline and a conversation visibly
shifted the bar's height, insets and type.

Introduces ConversationHeader, built from the main header's own tokens rather
than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph
in a 44dp slot so it lands exactly where the brand mark does, same -6dp
optical nudge pulling the title toward it, same 17sp label.

- Drops the back button; the close action on the right is the way out.
  Leaving a channel outright already lives on its row in the network sheet,
  so it does not need a second home beside the exit.
- Leading glyph is the transport: globe over the internet, wifi/bluetooth/
  routed on the mesh, matching the main header's channel button.
- Actions are right-aligned and unweighted -- favourite, encryption state,
  close -- so a long title yields space to them instead of pushing them off
  screen.
- Private chat titles use the primary green like every other header label,
  rather than orange for Nostr-reachable peers.

Height and edge insets now belong to each header variant instead of the
ChatFloatingHeader wrapper, which was applying them a second time to the
channel header.

Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth,
wifi, routed, close, check, warning, sync, lock_open, envelope -- so the
headers and peer rows no longer mix Material glyphs into the set.

* color
2026-07-27 17:33:11 +02:00

835 lines
34 KiB
Kotlin

package com.bitchat.android.ui
import com.bitchat.android.ui.theme.BitchatFontFamily
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.expandVertically
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatPalette
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.features.voice.normalizeAmplitudeSample
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
import com.bitchat.android.ui.media.ImagePickerButton
import com.bitchat.android.ui.media.FilePickerButton
/**
* Input components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
/**
* VisualTransformation that styles slash commands with background and color
* while preserving cursor positioning and click handling
*/
class SlashCommandVisualTransformation(
private val commandColor: Color,
private val commandBackground: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
val builder = AnnotatedString.Builder(text)
slashCommandRegex.findAll(text.text).forEach { match ->
builder.addStyle(
style = SpanStyle(
color = commandColor,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
background = commandBackground
),
start = match.range.first,
end = match.range.last + 1,
)
}
return TransformedText(
text = builder.toAnnotatedString(),
offsetMapping = OffsetMapping.Identity
)
}
}
/**
* VisualTransformation that styles mentions with background and color
* while preserving cursor positioning and click handling
*/
class MentionVisualTransformation(
private val mentionPeerIdentities: Map<String, PeerIdentity>,
private val palette: BitchatPalette,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val builder = AnnotatedString.Builder(text)
MENTION_TOKEN_REGEX.findAll(text.text).forEach { match ->
val start = match.range.first
val end = match.range.last + 1
val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 }
val suffixStart = suffixOffset?.let(start::plus) ?: end
val mentionColor = colorForMention(
mention = match.value,
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
)
// Keep the whole token on one continuous color-derived chip.
builder.addStyle(
style = SpanStyle(
background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA),
),
start = start,
end = end,
)
builder.addStyle(
style = SpanStyle(
color = mentionColor,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
),
start = start,
end = suffixStart,
)
if (suffixStart < end) {
builder.addStyle(
style = SpanStyle(
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
),
start = suffixStart,
end = end,
)
}
}
return TransformedText(
text = builder.toAnnotatedString(),
offsetMapping = OffsetMapping.Identity
)
}
}
/**
* VisualTransformation that combines multiple visual transformations
*/
class CombinedVisualTransformation(private val transformations: List<VisualTransformation>) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
var resultText = text
// Apply each transformation in order
transformations.forEach { transformation ->
resultText = transformation.filter(resultText).text
}
return TransformedText(
text = resultText,
offsetMapping = OffsetMapping.Identity
)
}
}
/**
* 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.
*
* Half of [ComposerMinHeight], so a single-line composer is a true capsule. Fixed rather than
* percentage-based so that when the field grows to several lines it stays a generously rounded
* rectangle instead of degenerating into a stadium.
*/
private val ComposerShape = RoundedCornerShape(ComposerMinHeight / 2)
/** Tap target for every button inside the pill. */
private val ComposerButtonSize = 40.dp
/** Diameter of the visible disc inside that tap target. */
private val ComposerButtonDisc = 36.dp
/** Icon size shared by the composer's glyphs. */
internal val ComposerIconSize = 20.dp
/**
* 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.
*
* 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 colorScheme = MaterialTheme.colorScheme
val accent = if (activeColor == Color.Unspecified) colorScheme.primary 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.inputButton,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerButtonContainer"
)
val tint by animateColorAsState(
targetValue = if (isActive) accent else colorScheme.onSurfaceVariant,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerButtonTint"
)
// A small dip on press. Spring rather than tween so the release overshoots very slightly and
// the button feels physical instead of merely animated.
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.88f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "composerButtonScale"
)
Box(
modifier = modifier.size(ComposerButtonSize),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(ComposerButtonDisc)
.scale(scale)
.background(container, CircleShape)
.semantics { contentDescription?.let { this.contentDescription = it } },
contentAlignment = Alignment.Center
) {
content(tint)
}
}
}
@Composable
fun MessageInput(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onSendVoiceNote: (String?, String?, String) -> Unit,
onSendImageNote: (String?, String?, String) -> Unit,
onSendFileNote: (String?, String?, String) -> Unit,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
showMediaButtons: Boolean,
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val isFocused = remember { mutableStateOf(false) }
val hasText = value.text.isNotBlank()
val focusRequester = remember { FocusRequester() }
var isRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
// Recording is the one state worth shouting about, so it overrides focus.
val borderColor by animateColorAsState(
targetValue = when {
isRecording -> colorScheme.error.copy(alpha = 0.7f)
isFocused.value -> palette.inputOutlineFocused
else -> palette.inputOutline
},
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerBorder"
)
// A barely-there lift on focus. Enough to register, not enough to look like a different
// component. Slightly translucent so the messages scrolling underneath stay faintly visible.
val containerColor by animateColorAsState(
targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
.copy(alpha = ComposerFillAlpha),
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerContainer"
)
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom
) {
// MARK: - The pill. Field and action buttons are one visual object.
Row(
modifier = Modifier
.weight(1f)
.heightIn(min = ComposerMinHeight)
// Grow smoothly as the field wraps to more lines rather than jumping a line at
// a time.
.animateContentSize(
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
)
.background(containerColor, ComposerShape)
.border(1.dp, borderColor, ComposerShape),
verticalAlignment = Alignment.Bottom
) {
Box(
modifier = Modifier
.weight(1f)
.padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp)
) {
// Always keep the text field mounted to retain focus and avoid IME collapse
BasicTextField(
value = value,
onValueChange = onValueChange,
// Near-white, not terminal green: this is the one place in the app where the
// user is composing rather than reading, and green-on-black is tiring to
// type into.
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.onSurface,
fontFamily = BitchatFontFamily
),
cursorBrush = SolidColor(
if (isRecording) Color.Transparent else colorScheme.onSurface
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend()
}),
// Cap the growth so a pasted wall of text cannot swallow the message list.
maxLines = 6,
visualTransformation = remember(
palette,
colorScheme.primary,
mentionPeerIdentities,
) {
CombinedVisualTransformation(
listOf(
SlashCommandVisualTransformation(
commandColor = colorScheme.primary,
commandBackground = colorScheme.primary.copy(alpha = 0.14f),
),
MentionVisualTransformation(
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
),
)
)
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
isFocused.value = focusState.isFocused
}
)
// Placeholder fades rather than blinking, which matters because it reappears
// every time a message is sent.
val placeholderAlpha by animateFloatAsState(
targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "placeholderAlpha"
)
if (placeholderAlpha > 0f) {
Text(
text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = BitchatFontFamily
),
color = palette.textTertiary,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.alpha(placeholderAlpha)
)
}
// Recording visualiser, layered over the (empty) field.
val waveformAlpha by animateFloatAsState(
targetValue = if (isRecording) 1f else 0f,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "waveformAlpha"
)
if (isRecording) {
Row(
modifier = Modifier
.fillMaxWidth()
.alpha(waveformAlpha),
verticalAlignment = Alignment.CenterVertically
) {
RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(22.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude)
)
Spacer(Modifier.width(12.dp))
val secs = (elapsedMs / 1000).toInt()
val maxSecs = 10 // 10 second max recording time
Text(
text = String.format(
"%02d:%02d / %02d:%02d",
secs / 60, secs % 60, maxSecs / 60, maxSecs % 60
),
fontFamily = BitchatFontFamily,
color = colorScheme.error,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
}
}
// MARK: - Action cluster, inside the pill.
//
// Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and
// scales between the two, and SizeTransform animates the width change, so typing the
// first character morphs camera+mic into send instead of snapping.
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
val latestChannel = rememberUpdatedState(currentChannel)
val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote)
AnimatedContent(
targetState = hasText,
transitionSpec = {
(
fadeIn(tween(BitchatMotion.STANDARD_MS)) +
scaleIn(
initialScale = 0.7f,
animationSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
)
)
).togetherWith(
fadeOut(tween(BitchatMotion.QUICK_MS)) +
scaleOut(
targetScale = 0.7f,
animationSpec = tween(
BitchatMotion.QUICK_MS,
easing = FastOutSlowInEasing
)
)
) using SizeTransform(clip = false) { _, _ ->
tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
}
},
modifier = Modifier.padding(end = 6.dp, bottom = 6.dp),
label = "composerActions"
) { showSend ->
if (showSend) {
SendButton(
isAccented = latestSelectedPeer.value != null || latestChannel.value != null,
onSend = onSend
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
if (showMediaButtons) {
// The camera steps aside while recording so the microphone is the
// only thing that can be released.
AnimatedVisibility(
visible = !isRecording,
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
expandHorizontally(
tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
),
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
shrinkHorizontally(
tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
)
) {
ImagePickerButton(
onImageReady = { outPath ->
onSendImageNote(
latestSelectedPeer.value,
latestChannel.value,
outPath
)
}
)
}
VoiceRecordButton(
isRecording = isRecording,
onStart = {
isRecording = true
elapsedMs = 0L
// Keep existing focus to avoid IME collapse, but do not
// force-show the keyboard.
if (isFocused.value) {
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
},
onAmplitude = { amp, ms ->
amplitude = amp
elapsedMs = ms
},
onFinish = { path ->
isRecording = false
// Extract and cache the waveform from the actual audio file
// so it matches the receiver's rendering.
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
if (arr != null) {
try {
com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr)
} catch (_: Exception) {}
}
}
latestOnSendVoiceNote.value(
latestSelectedPeer.value,
latestChannel.value,
path
)
},
// 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 {
// 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)
}
}
}
}
}
}
// Auto-stop handled inside VoiceRecordButton
}
/**
* Send affordance. Only rendered when there is something to send, so its mere presence is the
* signal; it does not need to shout in the terminal's full-brightness green as well.
*/
@Composable
private fun SendButton(
isAccented: Boolean,
onSend: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
ComposerActionSurface(
isActive = enabled,
isPressed = isPressed,
// Private chats and channels keep their orange identity, disc and glyph together.
activeColor = if (isAccented) palette.accentOrange else colorScheme.primary,
modifier = modifier.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled
) { onSend() }
) { tint ->
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(id = R.string.send_message),
modifier = Modifier.size(ComposerIconSize),
tint = tint
)
}
}
@Composable
fun CommandSuggestionsBox(
suggestions: List<CommandSuggestion>,
onSuggestionClick: (CommandSuggestion) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.background(colorScheme.surface)
.border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp))
.padding(vertical = 6.dp)
) {
suggestions.forEach { suggestion: CommandSuggestion ->
CommandSuggestionItem(
suggestion = suggestion,
onClick = { onSuggestionClick(suggestion) }
)
}
}
}
@Composable
fun CommandSuggestionItem(
suggestion: CommandSuggestion,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
// Roomier rows: at 3.dp vertical these were below a comfortable tap height.
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Show all aliases together
val allCommands = if (suggestion.aliases.isNotEmpty()) {
listOf(suggestion.command) + suggestion.aliases
} else {
listOf(suggestion.command)
}
Text(
text = allCommands.joinToString(", "),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium
),
color = colorScheme.primary,
fontSize = (BASE_FONT_SIZE - 2).sp
)
// Show syntax if any
suggestion.syntax?.let { syntax ->
Text(
text = syntax,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily
),
color = colorScheme.onSurfaceVariant,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
// Show description
Text(
text = suggestion.description,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily
),
color = palette.textTertiary,
fontSize = (BASE_FONT_SIZE - 4).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MentionSuggestionsBox(
suggestions: List<String>,
mentionPeerIdentities: Map<String, PeerIdentity>,
onSuggestionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
LazyColumn(
modifier = modifier
.heightIn(max = MentionSuggestionsMaxHeight)
.animateContentSize(
animationSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
)
)
.clip(MentionSuggestionsShape)
.background(colorScheme.surface)
.border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape),
contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding)
) {
items(
items = suggestions,
key = { suggestion -> suggestion.lowercase() }
) { suggestion ->
MentionSuggestionItem(
suggestion = suggestion,
userColor = colorForMention(
mention = suggestion,
mentionPeerIdentities = mentionPeerIdentities,
palette = palette,
),
onClick = { onSuggestionClick(suggestion) },
modifier = Modifier.animateItem(
fadeInSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
),
placementSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
),
fadeOutSpec = tween(BitchatMotion.QUICK_MS)
)
)
}
}
}
@Composable
fun MentionSuggestionItem(
suggestion: String,
userColor: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressedBackground by animateColorAsState(
targetValue = if (isPressed) {
userColor.copy(alpha = 0.10f)
} else {
Color.Transparent
},
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
label = "mentionSuggestionPressedBackground"
)
val pressedScale by animateFloatAsState(
targetValue = if (isPressed) 0.985f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "mentionSuggestionPressedScale"
)
Row(
modifier = modifier
.fillMaxWidth()
.height(MentionSuggestionRowHeight)
.scale(pressedScale)
.background(pressedBackground)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.mention_suggestion_at, suggestion),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold
),
color = userColor,
fontSize = (BASE_FONT_SIZE - 2).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.mention),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily
),
color = palette.textTertiary,
fontSize = (BASE_FONT_SIZE - 4).sp,
maxLines = 1
)
}
}
/** Mention autocomplete stays compact even in crowded channels. */
internal const val MaxVisibleMentionSuggestions = 5
private val MentionSuggestionRowHeight = 48.dp
private val MentionSuggestionsVerticalPadding = 6.dp
private val MentionSuggestionsMaxHeight =
(48 * MaxVisibleMentionSuggestions + 12).dp
private val MentionSuggestionsShape = RoundedCornerShape(8.dp)