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

231 lines
10 KiB
Kotlin

package com.bitchat.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.LocalBitchatPalette
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.model.BitchatMessage
/**
* User Action Sheet for selecting actions on a specific user (slap, hug, block)
* Design language matches LocationChannelsSheet.kt for consistency
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatUserSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
targetNickname: String,
selectedMessage: BitchatMessage? = null,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val standardGreen = colorScheme.primary
val standardBlue = colorScheme.secondary
val standardPurple = palette.accentPurple
val standardRed = colorScheme.error
val standardGrey = colorScheme.onSurfaceVariant
if (isPresented) {
BitchatBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Header
Text(
text = stringResource(R.string.at_nickname, targetNickname),
fontSize = 18.sp,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user),
fontSize = 12.sp,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// Action list (iOS-style plain list)
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
// Copy message action (only show if we have a message)
selectedMessage?.let { message ->
item {
UserActionRow(
title = stringResource(R.string.action_copy_message_title),
subtitle = stringResource(R.string.action_copy_message_subtitle),
titleColor = standardGrey,
onClick = {
// Copy the message content to clipboard
clipboardManager.setText(AnnotatedString(message.content))
onDismiss()
}
)
}
}
// Only show user actions for other users' messages or when no message is selected
if (selectedMessage?.sender != viewModel.nickname.value) {
// Send private message action
item {
UserActionRow(
title = stringResource(R.string.action_private_message_title, targetNickname),
subtitle = stringResource(R.string.action_private_message_subtitle),
titleColor = standardPurple,
onClick = {
val selectedLocationChannel = viewModel.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
val shortId = selectedMessage.senderPeerID!!.substring(6)
viewModel.startGeohashDMByShortId(shortId)
} else {
viewModel.startGeohashDMByNickname(targetNickname)
}
} else {
// Mesh chat
val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname)
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
}
}
onDismiss()
}
)
}
// Slap action
item {
UserActionRow(
title = stringResource(R.string.action_slap_title, targetNickname),
subtitle = stringResource(R.string.action_slap_subtitle),
titleColor = standardBlue,
onClick = {
// Send slap command
viewModel.sendMessage("/slap $targetNickname")
onDismiss()
}
)
}
// Hug action
item {
UserActionRow(
title = stringResource(R.string.action_hug_title, targetNickname),
subtitle = stringResource(R.string.action_hug_subtitle),
titleColor = standardGreen,
onClick = {
// Send hug command
viewModel.sendMessage("/hug $targetNickname")
onDismiss()
}
)
}
// Block action
item {
UserActionRow(
title = stringResource(R.string.action_block_title, targetNickname),
subtitle = stringResource(R.string.action_block_subtitle),
titleColor = standardRed,
onClick = {
// Check if we're in a geohash channel
val selectedLocationChannel = viewModel.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// Get user's nostr public key and add to geohash block list
viewModel.blockUserInGeohash(targetNickname)
} else {
// Regular mesh blocking
viewModel.sendMessage("/block $targetNickname")
}
onDismiss()
}
)
}
}
}
// Cancel button (iOS-style)
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.cancel_lower),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = BitchatFontFamily
)
}
}
}
}
}
@Composable
private fun UserActionRow(
title: String,
subtitle: String,
titleColor: Color,
onClick: () -> Unit
) {
// iOS-style list row (plain button, no card background)
Surface(
onClick = onClick,
color = Color.Transparent,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = title,
fontSize = BASE_FONT_SIZE.sp,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
color = titleColor
)
Text(
text = subtitle,
fontSize = 12.sp,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}