From 3dbe9c1c52412ddf3e5163b7adea7c65c89bdc2f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:50:46 +0200 Subject: [PATCH] lists --- .../com/bitchat/android/ui/AboutSections.kt | 69 +++ .../bitchat/android/ui/GeohashPeopleList.kt | 217 +++++---- .../android/ui/LocationChannelsSheet.kt | 81 +--- .../bitchat/android/ui/MeshPeerListSheet.kt | 453 +++++++++--------- 4 files changed, 423 insertions(+), 397 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index af17a509..8dfb63fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -68,6 +68,15 @@ internal val AboutHorizontalPadding = 20.dp /** Card corner radius for grouped rows. */ internal val AboutCardShape = RoundedCornerShape(16.dp) +/** Leading icon column in settings-style sheet rows. */ +internal val SheetRowLeadingSlot = 22.dp +internal val SheetRowLeadingGutter = 16.dp +internal val SheetRowHorizontal = 16.dp +internal val SheetRowVertical = 13.dp +internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter +/** Selection indicator sized for [SheetRowLeadingSlot]. */ +internal val SheetRowSelectedDot = 12.dp + /** * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. */ @@ -99,6 +108,66 @@ internal fun AboutSectionLabel( ) } +/** + * Icon + title on one line, optional short subtitle beneath. Used by location / network sheets. + */ +@Composable +internal fun SheetIconSectionHeader( + icon: ImageVector, + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } + } +} + +/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ +@Composable +internal fun SheetCardDivider() { + val palette = LocalBitchatPalette.current + HorizontalDivider( + modifier = Modifier.padding(start = SheetRowDividerInset), + thickness = 1.dp, + color = palette.outlineVariant + ) +} + /** * Centered app identity block: logo, wordmark, tagline, version. */ diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index cb3cc476..b29c12bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,13 +1,16 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Public import android.util.Log -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -17,25 +20,20 @@ import androidx.compose.ui.text.font.FontFamily 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.BASE_FONT_SIZE -import com.bitchat.android.ui.theme.LocalBitchatPalette -import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.ui.theme.LocalBitchatPalette +import java.util.* /** - * GeohashPeopleList - iOS-compatible component for displaying geohash participants - * Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers + * Geohash people list — card groups matching location / settings sheet rows. */ -/** - * GeoPerson data class - matches iOS GeoPerson structure exactly - */ data class GeoPerson( val id: String, // pubkey hex (lowercased) - matches iOS - val displayName: String, // nickname with #suffix - matches iOS + val displayName: String, // nickname with #suffix - matches iOS val lastSeen: Date // activity timestamp - matches iOS ) @@ -46,32 +44,41 @@ fun GeohashPeopleList( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - - // Observe geohash people from ChatViewModel + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - + val palette = LocalBitchatPalette.current Column(modifier = modifier) { if (geohashPeople.isEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_on_location)) - // Empty state - matches iOS "nobody around..." - Text( - text = stringResource(R.string.nobody_around), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textTertiary, - modifier = Modifier.padding( - horizontal = SheetHorizontalPadding + 14.dp, - vertical = 12.dp - ) + SheetIconSectionHeader( + icon = Icons.Outlined.LocationOn, + title = stringResource(R.string.section_on_location) ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Text( + text = stringResource(R.string.nobody_around), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ) + ) + } } else { - // Get current geohash identity for "me" detection val myHex = remember(selectedLocationChannel) { when (val channel = selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -89,30 +96,26 @@ fun GeohashPeopleList( else -> null } } - - // Sort people: me first, then by lastSeen (matches iOS exactly) + val orderedPeople = remember(geohashPeople, myHex) { geohashPeople.sortedWith { a, b -> when { myHex != null && a.id == myHex && b.id != myHex -> -1 myHex != null && b.id == myHex && a.id != myHex -> 1 - else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first + else -> b.lastSeen.compareTo(a.lastSeen) } } } - // Compute base name collisions to decide whether to show hash suffix val baseNameCounts = remember(geohashPeople) { val counts = mutableMapOf() geohashPeople.forEach { person -> - val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName) + val (b, _) = splitSuffix(person.displayName) counts[b] = (counts[b] ?: 0) + 1 } counts } - - // Split by presence: people physically in the geohash versus people who teleported - // in. Mixing them hid the fact that a "nearby" channel can contain remote users. + fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) { isTeleported } else { @@ -132,11 +135,9 @@ fun GeohashPeopleList( nickname = nickname, colorScheme = colorScheme, viewModel = viewModel, - showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1, onTap = { if (person.id != myHex) { - // TODO: Re-enable when NIP-17 geohash DM issues are fixed - // Start geohash DM (iOS-compatible) viewModel.startGeohashDM(person.id) onTapPerson() } @@ -145,13 +146,48 @@ fun GeohashPeopleList( } if (localPeople.isNotEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_on_location)) - localPeople.forEach { personRow(it) } + SheetIconSectionHeader( + icon = Icons.Outlined.LocationOn, + title = stringResource(R.string.section_on_location) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + localPeople.forEachIndexed { index, person -> + if (index > 0) SheetCardDivider() + personRow(person) + } + } + } } if (teleportedPeople.isNotEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_teleported_in)) - teleportedPeople.forEach { personRow(it) } + SheetIconSectionHeader( + icon = Icons.Outlined.Explore, + title = stringResource(R.string.section_teleported_in), + modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + teleportedPeople.forEachIndexed { index, person -> + if (index > 0) SheetCardDivider() + personRow(person) + } + } + } } } } @@ -172,69 +208,57 @@ private fun GeohashPersonItem( ) { val palette = LocalBitchatPalette.current - Surface( - onClick = onTap, - color = palette.surface, - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) - ) { + val (iconName, iconColor) = when { + isMe && isMyTeleported -> "face.dashed" to palette.accentOrange + isTeleported -> "face.dashed" to palette.textSecondary + isMe -> "face.smiling" to palette.accentOrange + else -> "face.smiling" to palette.textSecondary + } + val statusIcon = when (iconName) { + "face.dashed" -> Icons.Outlined.Explore + else -> Icons.Outlined.LocationOn + } + + val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = if (showHashSuffix) suffixRaw else "" + val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val baseColor = if (isMe) palette.accentOrange else assignedColor + Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(horizontal = 14.dp), + .clickable(onClick = onTap) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), verticalAlignment = Alignment.CenterVertically ) { - // Icon logic matching iOS exactly - if (hasUnreadDM) { - // Unread DM indicator (orange envelope) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(18.dp), - tint = palette.accentOrange - ) - } else { - // Face icon with teleportation state - val (iconName, iconColor) = when { - isMe && isMyTeleported -> "face.dashed" to palette.accentOrange - isTeleported -> "face.dashed" to palette.textSecondary - isMe -> "face.smiling" to palette.accentOrange - else -> "face.smiling" to palette.textSecondary + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center + ) { + if (hasUnreadDM) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(22.dp), + tint = palette.accentOrange + ) + } else { + Icon( + imageVector = statusIcon, + contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", + modifier = Modifier.size(22.dp), + tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) + ) } - - // Teleported users get the pin (they chose this location); locals get the person. - val icon = when (iconName) { - "face.dashed" -> Icons.Outlined.Explore - else -> Icons.Outlined.LocationOn - } - - Icon( - imageVector = icon, - contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(18.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) - ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with suffix handling - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - - // Get consistent peer color (matches iOS color assignment exactly) - val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) - val baseColor = if (isMe) palette.accentOrange else assignedColor - + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - // Base name with peer-specific color Text( text = baseName, fontFamily = FontFamily.Monospace, @@ -245,7 +269,6 @@ private fun GeohashPersonItem( overflow = TextOverflow.Ellipsis ) - // Suffix (collision-resistant #abcd) in lighter shade if (suffix.isNotEmpty()) { Text( text = suffix, @@ -256,7 +279,6 @@ private fun GeohashPersonItem( ) } - // "You" indicator for current user if (isMe) { Text( text = stringResource(R.string.you_suffix), @@ -267,7 +289,4 @@ private fun GeohashPersonItem( } } } - } } - - diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 43038419..28ecd8a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -60,13 +60,13 @@ import kotlinx.coroutines.launch * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. * Selection dots and row icons sit in this column so every option lines up with About settings. */ -private val ChannelLeadingSlot = 22.dp -private val ChannelLeadingGutter = 16.dp -private val ChannelRowHorizontal = 16.dp -private val ChannelRowVertical = 13.dp -private val ChannelDividerInset = ChannelRowHorizontal + ChannelLeadingSlot + ChannelLeadingGutter +private val ChannelLeadingSlot = SheetRowLeadingSlot +private val ChannelLeadingGutter = SheetRowLeadingGutter +private val ChannelRowHorizontal = SheetRowHorizontal +private val ChannelRowVertical = SheetRowVertical +private val ChannelDividerInset = SheetRowDividerInset /** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ -private val ChannelSelectedDot = 12.dp +private val ChannelSelectedDot = SheetRowSelectedDot /** * Location Channels sheet: grouped card rows matching About → Settings. @@ -187,7 +187,7 @@ fun LocationChannelsSheet( // Mesh section: icon + title header, offline subtitle, then selection card item(key = "mesh_card") { Column { - ChannelSectionHeader( + SheetIconSectionHeader( icon = Icons.Filled.Hub, title = stringResource(R.string.mesh_title), subtitle = stringResource(R.string.mesh_section_subtitle), @@ -223,7 +223,7 @@ fun LocationChannelsSheet( // Location channels: globe + title, geohash subtitle, nearby levels + teleport item(key = "channels_card") { Column { - ChannelSectionHeader( + SheetIconSectionHeader( icon = Icons.Outlined.Public, title = stringResource(R.string.location_channels_heading), subtitle = stringResource(R.string.location_channels_desc), @@ -241,7 +241,7 @@ fun LocationChannelsSheet( if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { nearbyChannels.forEachIndexed { index, channel -> - if (index > 0) ChannelCardDivider() + if (index > 0) SheetCardDivider() val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } @@ -269,10 +269,10 @@ fun LocationChannelsSheet( } ) } - ChannelCardDivider() + SheetCardDivider() } else if (showNearbyLoading) { ChannelLoadingRow() - ChannelCardDivider() + SheetCardDivider() } } @@ -352,7 +352,7 @@ fun LocationChannelsSheet( ) { Column { bookmarks.forEachIndexed { index, gh -> - if (index > 0) ChannelCardDivider() + if (index > 0) SheetCardDivider() val level = levelForLength(gh.length) val channel = GeohashChannel(level = level, geohash = gh) val coverage = coverageString(gh.length) @@ -498,63 +498,6 @@ fun LocationChannelsSheet( } } -@Composable -private fun ChannelCardDivider() { - val palette = LocalBitchatPalette.current - HorizontalDivider( - modifier = Modifier.padding(start = ChannelDividerInset), - thickness = 1.dp, - color = palette.outlineVariant - ) -} - -/** - * Section title row: icon + name on one line, optional short subtitle beneath. - */ -@Composable -private fun ChannelSectionHeader( - icon: ImageVector, - title: String, - subtitle: String, - modifier: Modifier = Modifier -) { - val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - Text( - text = title, - fontSize = 17.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - color = colorScheme.primary - ) - } - Text( - text = subtitle, - fontSize = 12.sp, - lineHeight = 17.sp, - fontFamily = FontFamily.Monospace, - color = palette.textSecondary - ) - } -} - /** * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 880c0cc6..026292fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* @@ -109,89 +110,83 @@ fun MeshPeerListSheet( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp) ) { - // Header: badge + live participant count - item(key = "people_header") { - val peopleCount = when (selectedLocationChannel) { - is ChannelID.Location -> geohashPeopleCount - else -> connectedPeers.count { it != viewModel.myPeerID } - } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - SheetHeaderBadge(icon = Icons.Filled.Person) - AnimatedCountLabel( - count = peopleCount, - text = stringResource(R.string.people_count_title, peopleCount), - fontSize = 20.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.primary - ) - } + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } } // Channels section if (joinedChannels.isNotEmpty()) { - item(key = "channels_header") { - SheetSectionLabel(text = stringResource(R.string.channels)) - } - - items( - items = joinedChannels.toList(), - key = { "channel_$it" } - ) { channel -> - val isSelected = channel == currentChannel - val unreadCount = unreadChannelMessages[channel] ?: 0 - - ChannelRow( - channel = channel, - isSelected = isSelected, - unreadCount = unreadCount, - colorScheme = colorScheme, - onChannelClick = { - // Check if this is a DM channel (starts with @) - if (channel.startsWith("@")) { - // Extract peer name and find the peer ID - val peerName = channel.removePrefix("@") - val peerID = - peerNicknames.entries.firstOrNull { it.value == peerName }?.key - if (peerID != null) { - viewModel.showPrivateChatSheet(peerID) - onDismiss() + item(key = "channels_section") { + Column { + SheetIconSectionHeader( + icon = Icons.Filled.Tag, + title = stringResource(R.string.channels), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = LocalBitchatPalette.current.surface, + shape = AboutCardShape + ) { + Column { + joinedChannels.toList().forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + if (channel.startsWith("@")) { + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + } else { + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + }, + ) } - } else { - // Regular channel switch - viewModel.switchToChannel(channel) - onDismiss() } - }, - onLeaveChannel = { - viewModel.leaveChannel(channel) - }, - ) + } + } } } - // People section - switch between mesh and geohash lists (iOS-compatible) + // People / geohash participants item(key = "people_section") { when (selectedLocationChannel) { is ChannelID.Location -> { - // Show geohash people list when in location channel GeohashPeopleList( viewModel = viewModel, - onTapPerson = onDismiss + onTapPerson = onDismiss, + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ) ) } else -> { - // Show mesh peer list when in mesh channel (default) PeopleSection( - modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp), + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ), connectedPeers = connectedPeers, peerNicknames = peerNicknames, peerRSSI = peerRSSI, @@ -199,6 +194,7 @@ fun MeshPeerListSheet( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, + peopleCount = peopleCount, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -239,8 +235,8 @@ fun MeshPeerListSheet( } } -/** Icon size for rows inside the sheets: one step down from the top bar. */ -private val PeerRowIconSize = 18.dp +/** Icon size for trailing actions on peer rows (matches settings glyph scale). */ +private val PeerRowIconSize = 22.dp @Composable private fun ChannelRow( @@ -253,54 +249,50 @@ private fun ChannelRow( ) { val palette = LocalBitchatPalette.current - Surface( - onClick = onChannelClick, - color = if (isSelected) palette.surfaceVariant else palette.surface, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onChannelClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Unread badge - if (unreadCount > 0) { - UnreadBadge( - count = unreadCount, - colorScheme = colorScheme - ) - } - + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } else if (unreadCount > 0) { + UnreadBadge(count = unreadCount, colorScheme = colorScheme) + } else { Text( - text = channel, + text = "#", fontFamily = FontFamily.Monospace, fontSize = 14.sp, - color = if (isSelected) colorScheme.primary else palette.textPrimary, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Leave channel button - CloseButton( - onClick = onLeaveChannel, + fontWeight = FontWeight.Medium, + color = palette.textTertiary ) } } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Text( + text = channel, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else palette.textPrimary, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + CloseButton(onClick = onLeaveChannel) } } @@ -316,6 +308,7 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), + peopleCount: Int = 0, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -327,19 +320,31 @@ fun PeopleSection( val palette = LocalBitchatPalette.current Column(modifier = modifier) { - SheetSectionLabel(text = stringResource(R.string.people)) + SheetIconSectionHeader( + icon = Icons.Filled.Person, + title = stringResource(R.string.people_count_title, peopleCount) + ) - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textTertiary, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp) - ) - } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical) + ) + } // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() @@ -443,7 +448,11 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } + var peerIndex = 0 + sortedPeers.forEach { peerID -> + if (peerIndex > 0) SheetCardDivider() + peerIndex++ val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -492,6 +501,9 @@ fun PeopleSection( val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) if (isFavoriteMappedToConnected(fav)) return@forEach + if (peerIndex > 0) SheetCardDivider() + peerIndex++ + val nostrConvKey: String? = try { FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) ?.let { ContactIdentityResolver.nostrAliasForPubkey(it) } @@ -535,6 +547,8 @@ fun PeopleSection( showHashSuffix = showHash ) } + } + } } } @@ -568,131 +582,112 @@ private fun PeerItem( val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark) val baseColor = if (isMe) palette.accentOrange else assignedColor - Surface( - onClick = onItemClick, - color = if (isSelected) palette.surfaceVariant else palette.surface, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onItemClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Connection/status indicator - if (hasUnreadDM) { - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentOrange - ) - } else if (showNostrGlobe) { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_reachable_via_nostr), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentPurple - ) - } else if (!isDirect && isFavorite) { - // Offline favourite: routed glyph, dimmed to read as unavailable. - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textTertiary - ) - } else { - Icon( - imageVector = when { - isWifiAware -> Icons.Filled.Wifi - isDirect -> Icons.Outlined.Bluetooth - else -> Icons.Filled.Route - }, - contentDescription = when { - isWifiAware -> "Direct Wi-Fi Aware" - isDirect -> "Direct Bluetooth" - else -> "Routed" - }, - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary - ) - } - - // Display name with iOS-style color and hashtag suffix support - Row(verticalAlignment = Alignment.CenterVertically) { - // Base name with peer-specific color - Text( - text = baseName, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, - color = baseColor.copy(alpha = SUFFIX_ALPHA) - ) - } - - if (isWifiAware && hasUnreadDM) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary - ) - } - } - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } else if (hasUnreadDM) { Icon( - imageVector = Icons.Filled.Verified, - contentDescription = stringResource(R.string.verify_title), + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentGreen + tint = palette.accentOrange + ) + } else if (showNostrGlobe) { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentPurple + ) + } else if (!isDirect && isFavorite) { + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.cd_offline_favorite), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textTertiary + ) + } else { + Icon( + imageVector = when { + isWifiAware -> Icons.Filled.Wifi + isDirect -> Icons.Outlined.Bluetooth + else -> Icons.Filled.Route + }, + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textSecondary + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = baseName, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (suffix.isNotEmpty()) { + Text( + text = suffix, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Favorite star with proper filled/outlined states - IconButton( - onClick = onToggleFavorite, - modifier = Modifier.size(44.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(PeerRowIconSize), - tint = if (isFavorite) palette.accentOrange else palette.textTertiary - ) - } + if (isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = palette.accentGreen + ) } } + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite) palette.accentOrange else palette.textTertiary + ) + } } }