Files
bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
T

320 lines
12 KiB
Kotlin

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.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.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.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.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.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService
import java.text.SimpleDateFormat
import java.util.*
/**
* 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
*/
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val currentChannel by viewModel.currentChannel.observeAsState()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
val showSidebar by viewModel.showSidebar.observeAsState(false)
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
var messageText by remember { mutableStateOf("") }
var showPasswordPrompt by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) }
var passwordInput by remember { mutableStateOf("") }
// Show password dialog when needed
LaunchedEffect(showPasswordPrompt) {
showPasswordDialog = showPasswordPrompt
}
val isConnected by viewModel.isConnected.observeAsState(false)
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
// Determine what messages to show
val displayMessages = when {
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> messages
}
// Use WindowInsets to handle keyboard properly
Box(modifier = Modifier.fillMaxSize()) {
val headerHeight = 36.dp
// Main content area that responds to keyboard/window insets
Column(
modifier = Modifier
.fillMaxSize()
.background(colorScheme.background)
.windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets
) {
// Header spacer - creates space for the floating header
Spacer(modifier = Modifier.height(headerHeight))
// Messages area - takes up available space, will compress when keyboard appears
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
modifier = Modifier.weight(1f)
)
// Input area - stays at bottom
ChatInputSection(
messageText = messageText,
onMessageTextChange = { newText: String ->
messageText = newText
viewModel.updateCommandSuggestions(newText)
},
onSend = {
if (messageText.trim().isNotEmpty()) {
viewModel.sendMessage(messageText.trim())
messageText = ""
}
},
showCommandSuggestions = showCommandSuggestions,
commandSuggestions = commandSuggestions,
onSuggestionClick = { suggestion: CommandSuggestion ->
messageText = viewModel.selectCommandSuggestion(suggestion)
},
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme
)
}
// Floating header - positioned absolutely at top, ignores keyboard
ChatFloatingHeader(
headerHeight = headerHeight,
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
colorScheme = colorScheme,
onSidebarToggle = { viewModel.showSidebar() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() }
)
// Sidebar overlay
AnimatedVisibility(
visible = showSidebar,
enter = slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(300, easing = EaseOutCubic)
) + fadeIn(animationSpec = tween(300)),
exit = slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(250, easing = EaseInCubic)
) + fadeOut(animationSpec = tween(250)),
modifier = Modifier.zIndex(2f)
) {
SidebarOverlay(
viewModel = viewModel,
onDismiss = { viewModel.hideSidebar() },
modifier = Modifier.fillMaxSize()
)
}
}
// 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 = ""
}
}
},
onPasswordDismiss = {
showPasswordDialog = false
passwordInput = ""
},
showAppInfo = showAppInfo,
onAppInfoDismiss = { viewModel.hideAppInfo() }
)
}
@Composable
private fun ChatInputSection(
messageText: String,
onMessageTextChange: (String) -> Unit,
onSend: () -> Unit,
showCommandSuggestions: Boolean,
commandSuggestions: List<CommandSuggestion>,
onSuggestionClick: (CommandSuggestion) -> Unit,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
colorScheme: ColorScheme
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.background,
shadowElevation = 8.dp
) {
Column {
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
// Command suggestions box
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
CommandSuggestionsBox(
suggestions = commandSuggestions,
onSuggestionClick = onSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(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 ChatFloatingHeader(
headerHeight: Dp,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
viewModel: ChatViewModel,
colorScheme: ColorScheme,
onSidebarToggle: () -> Unit,
onShowAppInfo: () -> Unit,
onPanicClear: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.zIndex(1f)
.windowInsetsPadding(WindowInsets.statusBars), // Only respond to status bar
color = colorScheme.background.copy(alpha = 0.95f),
shadowElevation = 8.dp
) {
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
)
)
}
// Divider under header
HorizontalDivider(
modifier = Modifier
.fillMaxWidth()
.offset(y = headerHeight)
.zIndex(1f),
color = colorScheme.outline.copy(alpha = 0.3f)
)
}
@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
)
}