diff --git a/CHAT_REFACTORING_PLAN.md b/CHAT_REFACTORING_PLAN.md new file mode 100644 index 00000000..934366cb --- /dev/null +++ b/CHAT_REFACTORING_PLAN.md @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt new file mode 100644 index 00000000..eec65c24 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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, + joinedChannels: Set, + hasUnreadChannels: Map, + hasUnreadPrivateMessages: Set, + 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, + 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 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index aed55fc8..c174578f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,5 +1,7 @@ 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 @@ -15,46 +17,35 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.animation.* -import androidx.compose.animation.core.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle 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.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import com.bitchat.android.R 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.* -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.core.view.WindowInsetsCompat -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable +/** + * Main ChatScreen - REFACTORED to use component-based architecture + * This is now a coordinator that orchestrates the following UI components: + * - ChatHeader: App bar, navigation, peer counter + * - MessageComponents: Message display and formatting + * - InputComponents: Message input and command suggestions + * - SidebarComponents: Navigation drawer with channels and people + * - DialogComponents: Password prompts and modals + * - ChatUIUtils: Utility functions for formatting and colors + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen(viewModel: ChatViewModel) { @@ -78,7 +69,6 @@ fun ChatScreen(viewModel: ChatViewModel) { var showPasswordDialog by remember { mutableStateOf(false) } var passwordInput by remember { mutableStateOf("") } var showAppInfo by remember { mutableStateOf(false) } - var tripleClickCount by remember { mutableStateOf(0) } // Show password dialog when needed LaunchedEffect(showPasswordPrompt) { @@ -86,8 +76,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } val isConnected by viewModel.isConnected.observeAsState(false) - val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) // Determine what messages to show @@ -122,208 +110,41 @@ fun ChatScreen(viewModel: ChatViewModel) { } // Input area - stays at bottom - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.background, - shadowElevation = 8.dp - ) { - Column { - Divider(color = colorScheme.outline.copy(alpha = 0.3f)) - - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = { suggestion -> - messageText = viewModel.selectCommandSuggestion(suggestion) - }, - modifier = Modifier.fillMaxWidth() - ) - - Divider(color = colorScheme.outline.copy(alpha = 0.2f)) - } - - MessageInput( - value = messageText, - onValueChange = { newText -> - messageText = newText - viewModel.updateCommandSuggestions(newText) - }, - onSend = { - if (messageText.trim().isNotEmpty()) { - viewModel.sendMessage(messageText.trim()) - messageText = "" - } - }, - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - - // Floating header - positioned absolutely at top, ignores keyboard - Surface( - modifier = Modifier - .fillMaxWidth() - .height(headerHeight) - .align(Alignment.TopCenter) - .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Only respond to status bar - color = colorScheme.background.copy(alpha = 0.95f), - shadowElevation = 4.dp - ) { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - when { - selectedPrivatePeer != null -> { - // Private chat header - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = { viewModel.endPrivateChat() } - ) { - Text( - text = "โ† back", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - val peerNickname = viewModel.meshService.getPeerNicknames()[selectedPrivatePeer] ?: selectedPrivatePeer ?: "Unknown" - 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 = { - selectedPrivatePeer?.let { peer -> - viewModel.toggleFavorite(peer) - } - } - ) { - val peer = selectedPrivatePeer - Text( - text = if (peer != null && viewModel.isFavorite(peer)) "โ˜…" else "โ˜†", - color = if (peer != null && viewModel.isFavorite(peer)) Color.Yellow else colorScheme.primary, - fontSize = 18.sp // Larger icon - ) - } - } - } - currentChannel != null -> { - // Channel header - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = { viewModel.switchToChannel(null) } - ) { - Text( - text = "โ† back", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "channel: $currentChannel", - style = MaterialTheme.typography.titleMedium, - color = Color(0xFF0080FF), // Blue - modifier = Modifier.clickable { showSidebar = true } - ) - - Spacer(modifier = Modifier.weight(1f)) - - TextButton( - onClick = { - currentChannel?.let { channel -> - viewModel.leaveChannel(channel) - } - } - ) { - Text( - text = "leave", - style = MaterialTheme.typography.bodySmall, - color = Color.Red - ) - } - } - } - else -> { - // Main header - 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 { - tripleClickCount++ - if (tripleClickCount >= 3) { - tripleClickCount = 0 - viewModel.panicClearAllData() - } else { - showAppInfo = true - } - } - ) - - Spacer(modifier = Modifier.width(8.dp)) - - NicknameEditor( - value = nickname, - onValueChange = viewModel::setNickname - ) - } - - PeerCounter( - connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, - joinedChannels = joinedChannels, - hasUnreadChannels = hasUnreadChannels, - hasUnreadPrivateMessages = hasUnreadPrivateMessages, - isConnected = isConnected, - onClick = { showSidebar = true } - ) - } - } - } + ChatInputSection( + messageText = messageText, + onMessageTextChange = { newText -> + messageText = newText + viewModel.updateCommandSuggestions(newText) + }, + onSend = { + if (messageText.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.trim()) + messageText = "" } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent - ) + showCommandSuggestions = showCommandSuggestions, + commandSuggestions = commandSuggestions, + onSuggestionClick = { suggestion -> + messageText = viewModel.selectCommandSuggestion(suggestion) + }, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + colorScheme = colorScheme ) } - // Divider under header - Divider( - color = colorScheme.outline.copy(alpha = 0.3f), - modifier = Modifier - .fillMaxWidth() - .offset(y = headerHeight) - .zIndex(1f) + // Floating header - positioned absolutely at top, ignores keyboard + ChatFloatingHeader( + headerHeight = headerHeight, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + colorScheme = colorScheme, + onSidebarToggle = { showSidebar = true }, + onShowAppInfo = { showAppInfo = true }, + onPanicClear = { viewModel.panicClearAllData() } ) // Sidebar overlay @@ -347,894 +168,156 @@ fun ChatScreen(viewModel: ChatViewModel) { } } - // Password dialog - if (showPasswordDialog && passwordPromptChannel != null) { - AlertDialog( - onDismissRequest = { - showPasswordDialog = false - passwordInput = "" - }, - title = { - Text( - text = "Enter Channel Password", - style = MaterialTheme.typography.titleMedium, - color = colorScheme.onSurface - ) - }, - text = { - Column { - Text( - text = "Channel $passwordPromptChannel 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 = { passwordInput = it }, - label = { Text("Password", style = MaterialTheme.typography.bodyMedium) }, - textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colorScheme.primary, - unfocusedBorderColor = colorScheme.outline - ) - ) + // Dialogs + ChatDialogs( + showPasswordDialog = showPasswordDialog, + passwordPromptChannel = passwordPromptChannel, + passwordInput = passwordInput, + onPasswordChange = { passwordInput = it }, + onPasswordConfirm = { + if (passwordInput.isNotEmpty()) { + val success = viewModel.joinChannel(passwordPromptChannel!!, passwordInput) + if (success) { + showPasswordDialog = false + passwordInput = "" } - }, - confirmButton = { - TextButton( - onClick = { - if (passwordInput.isNotEmpty()) { - val success = viewModel.joinChannel(passwordPromptChannel!!, passwordInput) - if (success) { - showPasswordDialog = false - passwordInput = "" - } - } - } - ) { - Text( - text = "Join", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) - } - }, - dismissButton = { - TextButton( - onClick = { - showPasswordDialog = false - passwordInput = "" - } - ) { - Text( - text = "Cancel", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.onSurface - ) - } - }, - containerColor = colorScheme.surface, - tonalElevation = 8.dp - ) - } -} - -@Composable -private fun NicknameEditor( - value: String, - onValueChange: (String) -> Unit -) { - val colorScheme = MaterialTheme.colorScheme - val focusManager = LocalFocusManager.current - - Row(verticalAlignment = Alignment.CenterVertically) { - 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 -private fun PeerCounter( - connectedPeers: List, - joinedChannels: Set, - hasUnreadChannels: Map, - hasUnreadPrivateMessages: Set, - isConnected: Boolean, - onClick: () -> Unit -) { - 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 -private fun MessagesList( - messages: List, - currentUserNickname: String, - meshService: BluetoothMeshService, - modifier: Modifier = Modifier -) { - val listState = rememberLazyListState() - val colorScheme = MaterialTheme.colorScheme - - // 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 -private fun MessageItem( - message: BitchatMessage, - currentUserNickname: String, - meshService: BluetoothMeshService -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - // Single text view for natural wrapping (like iOS) - Text( - text = formatMessageAsAnnotatedString(message, currentUserNickname, meshService, colorScheme), - 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) } - } - } + }, + onPasswordDismiss = { + showPasswordDialog = false + passwordInput = "" + }, + showAppInfo = showAppInfo, + onAppInfoDismiss = { showAppInfo = false } + ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun formatMessageAsAnnotatedString( - message: BitchatMessage, - currentUserNickname: String, - meshService: BluetoothMeshService, - colorScheme: ColorScheme -): androidx.compose.ui.text.AnnotatedString { - val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - val builder = androidx.compose.ui.text.AnnotatedString.Builder() - - // Timestamp - val timestampColor = if (message.sender == "system") Color.Gray else colorScheme.primary.copy(alpha = 0.7f) - builder.pushStyle(androidx.compose.ui.text.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(androidx.compose.ui.text.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(androidx.compose.ui.text.SpanStyle( - color = Color.Gray, - fontSize = 12.sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic - )) - builder.append("* ${message.content} *") - builder.pop() - } - - return builder.toAnnotatedString() -} - -private fun appendFormattedContent( - builder: androidx.compose.ui.text.AnnotatedString.Builder, - content: String, - mentions: List?, - 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(androidx.compose.ui.text.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(androidx.compose.ui.text.SpanStyle( - color = Color(0xFF0080FF), // Blue - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline - )) - } - "mention" -> { - builder.pushStyle(androidx.compose.ui.text.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(androidx.compose.ui.text.SpanStyle( - color = colorScheme.primary, - fontSize = 14.sp, - fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal - )) - builder.append(remainingText) - builder.pop() - } -} - -@Composable -private 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) - ) - } - } -} - -@Composable -private fun MessageInput( - value: String, - onValueChange: (String) -> Unit, +private fun ChatInputSection( + messageText: String, + onMessageTextChange: (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 -private 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() - val myPeerID = viewModel.meshService.myPeerID - - 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 - ) { - // Header - match main toolbar height (matches iOS) - 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)) - } - - 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 ChannelsSection( - channels: List, - 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 -private fun PeopleSection( - connectedPeers: List, - peerNicknames: Map, - peerRSSI: Map, - 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 -> - val isSelected = peerID == selectedPrivatePeer - val isFavorite = viewModel.isFavorite(peerID) - val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID) - val signalStrength = peerRSSI[peerID] ?: 0 - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onPrivateChatStart(peerID) } - .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 - 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)) - } - } - - 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 = { viewModel.toggleFavorite(peerID) }, - 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) - ) - } - } - } - } - } -} - -private 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 - } -} - -@Composable -private fun CommandSuggestionsBox( - suggestions: List, + showCommandSuggestions: Boolean, + commandSuggestions: List, onSuggestionClick: (ChatViewModel.CommandSuggestion) -> Unit, - modifier: Modifier = Modifier + selectedPrivatePeer: String?, + currentChannel: String?, + nickname: String, + colorScheme: ColorScheme ) { - 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) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.background, + shadowElevation = 8.dp ) { - suggestions.forEach { suggestion -> - CommandSuggestionItem( - suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) } + Column { + Divider(color = colorScheme.outline.copy(alpha = 0.3f)) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + + Divider(color = colorScheme.outline.copy(alpha = 0.2f)) + } + + MessageInput( + value = messageText, + onValueChange = onMessageTextChange, + onSend = onSend, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + modifier = Modifier.fillMaxWidth() ) } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CommandSuggestionItem( - suggestion: ChatViewModel.CommandSuggestion, - onClick: () -> Unit +private fun ChatFloatingHeader( + headerHeight: Dp, + selectedPrivatePeer: String?, + currentChannel: String?, + nickname: String, + viewModel: ChatViewModel, + colorScheme: ColorScheme, + onSidebarToggle: () -> Unit, + onShowAppInfo: () -> Unit, + onPanicClear: () -> Unit ) { - val colorScheme = MaterialTheme.colorScheme - - Row( + Surface( modifier = Modifier .fillMaxWidth() - .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), - verticalAlignment = Alignment.CenterVertically + .height(headerHeight) + .zIndex(1f) + .windowInsetsPadding(WindowInsets.statusBars), // Only respond to status bar + color = colorScheme.background.copy(alpha = 0.95f), + shadowElevation = 8.dp ) { - // 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 + TopAppBar( + title = { + ChatHeaderContent( + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + viewModel = viewModel, + onBackClick = { + when { + selectedPrivatePeer != null -> viewModel.endPrivateChat() + currentChannel != null -> viewModel.switchToChannel(null) + } + }, + onSidebarClick = onSidebarToggle, + onTripleClick = onPanicClear, + onShowAppInfo = onShowAppInfo + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent ) - } - - 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 ) } + + // Divider under header + Divider( + color = colorScheme.outline.copy(alpha = 0.3f), + modifier = Modifier + .fillMaxWidth() + .offset(y = headerHeight) + .zIndex(1f) + ) +} + +@Composable +private fun ChatDialogs( + showPasswordDialog: Boolean, + passwordPromptChannel: String?, + passwordInput: String, + onPasswordChange: (String) -> Unit, + onPasswordConfirm: () -> Unit, + onPasswordDismiss: () -> Unit, + showAppInfo: Boolean, + onAppInfoDismiss: () -> Unit +) { + // Password dialog + PasswordPromptDialog( + show = showPasswordDialog, + channelName = passwordPromptChannel, + passwordInput = passwordInput, + onPasswordChange = onPasswordChange, + onConfirm = onPasswordConfirm, + onDismiss = onPasswordDismiss + ) + + // App info dialog + AppInfoDialog( + show = showAppInfo, + onDismiss = onAppInfoDismiss + ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt new file mode 100644 index 00000000..6972106f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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?, + 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() + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt b/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt new file mode 100644 index 00000000..1051233a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt new file mode 100644 index 00000000..980bd848 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -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, + 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 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt new file mode 100644 index 00000000..f032182f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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, + 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) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt new file mode 100644 index 00000000..4d63d33d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -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, + 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, + peerNicknames: Map, + peerRSSI: Map, + 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)) + } + } +}