mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 01:05: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