This commit is contained in:
callebtc
2025-07-09 00:34:47 +02:00
parent 849b0845c4
commit 6d22aa7315
8 changed files with 1546 additions and 1090 deletions
+64
View File
@@ -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))
}
}
}