mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +00:00
nice
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# ChatScreen.kt Refactoring Plan
|
||||
|
||||
## Current State
|
||||
- Single file: `ChatScreen.kt` (~1,100+ lines)
|
||||
- Multiple UI responsibilities mixed together
|
||||
- Hard to maintain and test individual components
|
||||
|
||||
## Proposed Component Structure
|
||||
|
||||
### 1. Main Screen (ChatScreen.kt)
|
||||
**Responsibilities:**
|
||||
- Main layout orchestration
|
||||
- State management delegation
|
||||
- Window insets handling
|
||||
- Component coordination
|
||||
|
||||
### 2. Header Components (ChatHeader.kt)
|
||||
**Responsibilities:**
|
||||
- TopAppBar with different states (main, private, channel)
|
||||
- Nickname editor
|
||||
- Peer counter with status indicators
|
||||
- Navigation controls
|
||||
|
||||
### 3. Message Components (MessageComponents.kt)
|
||||
**Responsibilities:**
|
||||
- MessagesList composable
|
||||
- MessageItem with formatting
|
||||
- Message text parsing and styling
|
||||
- Delivery status indicators
|
||||
- RSSI-based coloring
|
||||
|
||||
### 4. Input Components (InputComponents.kt)
|
||||
**Responsibilities:**
|
||||
- MessageInput with different modes
|
||||
- Command suggestions box
|
||||
- Command suggestion items
|
||||
- Input validation and handling
|
||||
|
||||
### 5. Sidebar Components (SidebarComponents.kt)
|
||||
**Responsibilities:**
|
||||
- SidebarOverlay with navigation
|
||||
- ChannelsSection for channel management
|
||||
- PeopleSection for peer list
|
||||
- Sidebar state management
|
||||
|
||||
### 6. Dialog Components (DialogComponents.kt)
|
||||
**Responsibilities:**
|
||||
- Password prompt dialog
|
||||
- App info dialog
|
||||
- Other modal dialogs
|
||||
|
||||
### 7. UI Utils (ChatUIUtils.kt)
|
||||
**Responsibilities:**
|
||||
- RSSI color mapping
|
||||
- Text formatting utilities
|
||||
- Common styling constants
|
||||
- Helper functions
|
||||
|
||||
## Benefits
|
||||
- Each file has a single, clear responsibility
|
||||
- Components are easier to test in isolation
|
||||
- Better code organization and navigation
|
||||
- Simplified debugging
|
||||
- Easier to add new UI features
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun NicknameEditor(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = "@",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary.copy(alpha = 0.8f)
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
modifier = Modifier.widthIn(max = 100.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeerCounter(
|
||||
connectedPeers: List<String>,
|
||||
joinedChannels: Set<String>,
|
||||
hasUnreadChannels: Map<String, Int>,
|
||||
hasUnreadPrivateMessages: Set<String>,
|
||||
isConnected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.clickable { onClick() }
|
||||
) {
|
||||
if (hasUnreadChannels.values.any { it > 0 }) {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF0080FF),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
Text(
|
||||
text = "✉",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFFFF8C00),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = "Connected peers",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isConnected) Color(0xFF00C851) else Color.Red
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${connectedPeers.size}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
Text(
|
||||
text = " · ⧉ ${joinedChannels.size}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatHeaderContent(
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
viewModel: ChatViewModel,
|
||||
onBackClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
onTripleClick: () -> Unit,
|
||||
onShowAppInfo: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
var tripleClickCount by remember { mutableStateOf(0) }
|
||||
|
||||
when {
|
||||
selectedPrivatePeer != null -> {
|
||||
// Private chat header
|
||||
PrivateChatHeader(
|
||||
peerID = selectedPrivatePeer,
|
||||
peerNicknames = viewModel.meshService.getPeerNicknames(),
|
||||
isFavorite = viewModel.isFavorite(selectedPrivatePeer),
|
||||
onBackClick = onBackClick,
|
||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
||||
)
|
||||
}
|
||||
currentChannel != null -> {
|
||||
// Channel header
|
||||
ChannelHeader(
|
||||
channel = currentChannel,
|
||||
onBackClick = onBackClick,
|
||||
onLeaveChannel = { viewModel.leaveChannel(currentChannel) },
|
||||
onSidebarClick = onSidebarClick
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// Main header
|
||||
MainHeader(
|
||||
nickname = nickname,
|
||||
onNicknameChange = viewModel::setNickname,
|
||||
onTitleClick = {
|
||||
tripleClickCount++
|
||||
if (tripleClickCount >= 3) {
|
||||
tripleClickCount = 0
|
||||
onTripleClick()
|
||||
} else {
|
||||
onShowAppInfo()
|
||||
}
|
||||
},
|
||||
onSidebarClick = onSidebarClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivateChatHeader(
|
||||
peerID: String,
|
||||
peerNicknames: Map<String, String>,
|
||||
isFavorite: Boolean,
|
||||
onBackClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val peerNickname = peerNicknames[peerID] ?: peerID
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Text(
|
||||
text = "← back",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("🔒", fontSize = 16.sp) // Slightly larger
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = peerNickname,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFFFF8C00) // Orange
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Favorite button
|
||||
IconButton(onClick = onToggleFavorite) {
|
||||
Text(
|
||||
text = if (isFavorite) "★" else "☆",
|
||||
color = if (isFavorite) Color.Yellow else colorScheme.primary,
|
||||
fontSize = 18.sp // Larger icon
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelHeader(
|
||||
channel: String,
|
||||
onBackClick: () -> Unit,
|
||||
onLeaveChannel: () -> Unit,
|
||||
onSidebarClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Text(
|
||||
text = "← back",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "channel: $channel",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF0080FF), // Blue
|
||||
modifier = Modifier.clickable { onSidebarClick() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(onClick = onLeaveChannel) {
|
||||
Text(
|
||||
text = "leave",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainHeader(
|
||||
nickname: String,
|
||||
onNicknameChange: (String) -> Unit,
|
||||
onTitleClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "bitchat*",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier.clickable { onTitleClick() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
NicknameEditor(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange
|
||||
)
|
||||
}
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
||||
isConnected = isConnected,
|
||||
onClick = onSidebarClick
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Utility functions for ChatScreen UI components
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get RSSI-based color for signal strength visualization
|
||||
*/
|
||||
fun getRSSIColor(rssi: Int): Color {
|
||||
return when {
|
||||
rssi >= -50 -> Color(0xFF00FF00) // Bright green
|
||||
rssi >= -60 -> Color(0xFF80FF00) // Green-yellow
|
||||
rssi >= -70 -> Color(0xFFFFFF00) // Yellow
|
||||
rssi >= -80 -> Color(0xFFFF8000) // Orange
|
||||
else -> Color(0xFFFF4444) // Red
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message as annotated string with proper styling
|
||||
*/
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
|
||||
// Timestamp
|
||||
val timestampColor = if (message.sender == "system") Color.Gray else colorScheme.primary.copy(alpha = 0.7f)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = timestampColor,
|
||||
fontSize = 12.sp
|
||||
))
|
||||
builder.append("[${timeFormatter.format(message.timestamp)}] ")
|
||||
builder.pop()
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Sender
|
||||
val senderColor = when {
|
||||
message.sender == currentUserNickname -> colorScheme.primary
|
||||
else -> {
|
||||
val peerID = message.senderPeerID
|
||||
val rssi = peerID?.let { meshService.getPeerRSSI()[it] } ?: -60
|
||||
getRSSIColor(rssi)
|
||||
}
|
||||
}
|
||||
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = senderColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
))
|
||||
builder.append("<@${message.sender}> ")
|
||||
builder.pop()
|
||||
|
||||
// Message content with mentions and hashtags highlighted
|
||||
appendFormattedContent(builder, message.content, message.mentions, currentUserNickname, colorScheme)
|
||||
|
||||
} else {
|
||||
// System message
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp,
|
||||
fontStyle = FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Append formatted content with hashtag and mention highlighting
|
||||
*/
|
||||
private fun appendFormattedContent(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
mentions: List<String>?,
|
||||
currentUserNickname: String,
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
// Parse hashtags and mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([a-zA-Z0-9_]+)".toRegex()
|
||||
|
||||
val hashtagMatches = hashtagPattern.findAll(content).toList()
|
||||
val mentionMatches = mentionPattern.findAll(content).toList()
|
||||
|
||||
// Combine and sort all matches
|
||||
val allMatches = (hashtagMatches.map { it.range to "hashtag" } +
|
||||
mentionMatches.map { it.range to "mention" })
|
||||
.sortedBy { it.first.first }
|
||||
|
||||
var lastEnd = 0
|
||||
|
||||
for ((range, type) in allMatches) {
|
||||
// Add text before the match
|
||||
if (lastEnd < range.first) {
|
||||
val beforeText = content.substring(lastEnd, range.first)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Add the styled match
|
||||
val matchText = content.substring(range.first, range.last + 1)
|
||||
when (type) {
|
||||
"hashtag" -> {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF0080FF), // Blue
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
|
||||
))
|
||||
}
|
||||
"mention" -> {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFFFF8C00), // Orange
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
))
|
||||
}
|
||||
}
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
|
||||
lastEnd = range.last + 1
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastEnd < content.length) {
|
||||
val remainingText = content.substring(lastEnd)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Dialog components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun PasswordPromptDialog(
|
||||
show: Boolean,
|
||||
channelName: String?,
|
||||
passwordInput: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
if (show && channelName != null) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Enter Channel Password",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = "Channel $channelName is password protected. Enter the password to join.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = passwordInput,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text("Password", style = MaterialTheme.typography.bodyMedium) },
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colorScheme.primary,
|
||||
unfocusedBorderColor = colorScheme.outline
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(
|
||||
text = "Join",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = colorScheme.surface,
|
||||
tonalElevation = 8.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppInfoDialog(
|
||||
show: Boolean,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
if (show) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "About bitchat*",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "Decentralized mesh messaging over Bluetooth LE\n\n" +
|
||||
"• No servers or internet required\n" +
|
||||
"• End-to-end encrypted private messages\n" +
|
||||
"• Password-protected channels\n" +
|
||||
"• Store-and-forward for offline peers\n\n" +
|
||||
"Triple-click title to emergency clear all data",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "OK",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = colorScheme.surface,
|
||||
tonalElevation = 8.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Input components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun MessageInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Prompt
|
||||
Text(
|
||||
text = when {
|
||||
selectedPrivatePeer != null -> "<@$nickname> →"
|
||||
currentChannel != null -> "<@$nickname> →" // Could show if channel is encrypted
|
||||
else -> "<@$nickname>"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||
color = when {
|
||||
selectedPrivatePeer != null -> Color(0xFFFF8C00) // Orange for private
|
||||
currentChannel != null -> Color(0xFFFF8C00) // Orange if encrypted channel
|
||||
else -> colorScheme.primary
|
||||
},
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Text input
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
|
||||
|
||||
// Send button - smaller with light green background
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier.size(32.dp) // Reduced from 40dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp) // Reduced size
|
||||
.background(
|
||||
color = Color(0xFF00C851).copy(alpha = 0.15f), // Light green background
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "↑",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 16.sp), // Smaller arrow
|
||||
color = Color(0xFF00C851) // Green arrow
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CommandSuggestionsBox(
|
||||
suggestions: List<ChatViewModel.CommandSuggestion>,
|
||||
onSuggestionClick: (ChatViewModel.CommandSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(colorScheme.surface)
|
||||
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
CommandSuggestionItem(
|
||||
suggestion = suggestion,
|
||||
onClick = { onSuggestionClick(suggestion) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CommandSuggestionItem(
|
||||
suggestion: ChatViewModel.CommandSuggestion,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.1f)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 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 = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
color = colorScheme.primary,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
|
||||
// Show syntax if any
|
||||
suggestion.syntax?.let { syntax ->
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = syntax,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
fontSize = 10.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Show description
|
||||
Text(
|
||||
text = suggestion.description,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Message display components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun MessagesList(
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(messages) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageItem(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Single text view for natural wrapping (like iOS)
|
||||
Text(
|
||||
text = formatMessageAsAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
|
||||
// Delivery status for private messages
|
||||
if (message.isPrivate && message.sender == currentUserNickname) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
DeliveryStatusIcon(status = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
when (status) {
|
||||
is DeliveryStatus.Sending -> {
|
||||
Text(
|
||||
text = "○",
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Sent -> {
|
||||
Text(
|
||||
text = "✓",
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Delivered -> {
|
||||
Text(
|
||||
text = "✓✓",
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Read -> {
|
||||
Text(
|
||||
text = "✓✓",
|
||||
fontSize = 10.sp,
|
||||
color = Color(0xFF007AFF), // Blue
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.Failed -> {
|
||||
Text(
|
||||
text = "⚠",
|
||||
fontSize = 10.sp,
|
||||
color = Color.Red.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.PartiallyDelivered -> {
|
||||
Text(
|
||||
text = "✓${status.reached}/${status.total}",
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* Sidebar components for ChatScreen
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun SidebarOverlay(
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
|
||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
|
||||
// Get peer data from mesh service
|
||||
val peerNicknames = viewModel.meshService.getPeerNicknames()
|
||||
val peerRSSI = viewModel.meshService.getPeerRSSI()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.clickable { onDismiss() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(280.dp)
|
||||
.align(Alignment.CenterEnd)
|
||||
.clickable { /* Prevent dismissing when clicking sidebar */ }
|
||||
) {
|
||||
// Grey vertical bar for visual continuity (matches iOS)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(1.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.3f))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f)
|
||||
.background(colorScheme.surface)
|
||||
.windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding
|
||||
) {
|
||||
SidebarHeader()
|
||||
|
||||
Divider()
|
||||
|
||||
// Scrollable content
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Channels section
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
item {
|
||||
ChannelsSection(
|
||||
channels = joinedChannels.toList(), // Convert Set to List
|
||||
currentChannel = currentChannel,
|
||||
colorScheme = colorScheme,
|
||||
onChannelClick = { channel ->
|
||||
viewModel.switchToChannel(channel)
|
||||
onDismiss()
|
||||
},
|
||||
onLeaveChannel = { channel ->
|
||||
viewModel.leaveChannel(channel)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Divider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// People section
|
||||
item {
|
||||
PeopleSection(
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.startPrivateChat(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarHeader() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(36.dp) // Match reduced main header height
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.surface.copy(alpha = 0.95f))
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "YOUR NETWORK",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelsSection(
|
||||
channels: List<String>,
|
||||
currentChannel: String?,
|
||||
colorScheme: ColorScheme,
|
||||
onChannelClick: (String) -> Unit,
|
||||
onLeaveChannel: (String) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person, // Using Person icon as placeholder
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(10.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "CHANNELS",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
channels.forEach { channel ->
|
||||
val isSelected = channel == currentChannel
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChannelClick(channel) }
|
||||
.background(
|
||||
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "#$channel",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Leave channel button
|
||||
IconButton(
|
||||
onClick = { onLeaveChannel(channel) },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Leave channel",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeopleSection(
|
||||
connectedPeers: List<String>,
|
||||
peerNicknames: Map<String, String>,
|
||||
peerRSSI: Map<String, Int>,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
selectedPrivatePeer: String?,
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person, // Using Person icon for people
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(10.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "PEOPLE",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
Text(
|
||||
text = "No one connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
// Sort peers: favorites first, then by nickname
|
||||
val sortedPeers = connectedPeers.sortedWith { peer1, peer2 ->
|
||||
val isFav1 = viewModel.isFavorite(peer1)
|
||||
val isFav2 = viewModel.isFavorite(peer2)
|
||||
|
||||
when {
|
||||
isFav1 && !isFav2 -> -1
|
||||
!isFav1 && isFav2 -> 1
|
||||
else -> {
|
||||
val name1 = if (peer1 == nickname) "You" else (peerNicknames[peer1] ?: peer1)
|
||||
val name2 = if (peer2 == nickname) "You" else (peerNicknames[peer2] ?: peer2)
|
||||
name1.compareTo(name2, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
|
||||
signalStrength = peerRSSI[peerID] ?: 0,
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = viewModel.isFavorite(peerID),
|
||||
colorScheme = colorScheme,
|
||||
onItemClick = { onPrivateChatStart(peerID) },
|
||||
onToggleFavorite = { viewModel.toggleFavorite(peerID) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PeerItem(
|
||||
peerID: String,
|
||||
displayName: String,
|
||||
signalStrength: Int,
|
||||
isSelected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
onItemClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onItemClick() }
|
||||
.background(
|
||||
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Signal strength indicators
|
||||
SignalStrengthIndicator(
|
||||
signalStrength = signalStrength,
|
||||
colorScheme = colorScheme
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Favorite star
|
||||
IconButton(
|
||||
onClick = onToggleFavorite,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignalStrengthIndicator(
|
||||
signalStrength: Int,
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
Row(modifier = Modifier.width(24.dp)) {
|
||||
repeat(3) { index ->
|
||||
val opacity = when {
|
||||
signalStrength >= (index + 1) * 33 -> 1f
|
||||
else -> 0.2f
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 3.dp, height = (4 + index * 2).dp)
|
||||
.background(
|
||||
colorScheme.onSurface.copy(alpha = opacity),
|
||||
RoundedCornerShape(1.dp)
|
||||
)
|
||||
)
|
||||
if (index < 2) Spacer(modifier = Modifier.width(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user