Coverage Summary for Class: ChatScreenKt (debug.com.greybox.projectmesh.messaging.ui.screens)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| ChatScreenKt$ChatScreen$$inlined$instance$default$1 |
0%
(0/1)
|
|
| ChatScreenKt$ChatScreen$2 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/5)
|
| ChatScreenKt$ChatScreen$3 |
0%
(0/1)
|
0%
(0/7)
|
0%
(0/7)
|
0%
(0/57)
|
| ChatScreenKt$ChatScreen$4$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/4)
|
| ChatScreenKt$ChatScreen$4$2$2$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/4)
|
| ChatScreenKt$ChatScreen$4$2$3 |
0%
(0/1)
|
0%
(0/4)
|
0%
(0/4)
|
0%
(0/26)
|
| ChatScreenKt$ChatScreen$5 |
|
| ChatScreenKt$ChatScreen$pickFileLauncher$1 |
0%
(0/1)
|
|
| ChatScreenKt$ChatScreen$textMessage$2 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/5)
|
| ChatScreenKt$ChatScreen$userInfo$1$1 |
0%
(0/1)
|
0%
(0/6)
|
0%
(0/3)
|
0%
(0/40)
|
| ChatScreenKt$DisplayAllMessages$1 |
0%
(0/1)
|
0%
(0/1)
|
0%
(0/2)
|
0%
(0/27)
|
| ChatScreenKt$DisplayAllMessages$2 |
0%
(0/1)
|
|
| ChatScreenKt$DisplayAllMessages$2$1 |
0%
(0/1)
|
|
| ChatScreenKt$DisplayAllMessages$2$2$1$1 |
0%
(0/1)
|
|
0%
(0/5)
|
0%
(0/31)
|
| ChatScreenKt$DisplayAllMessages$2$2$sender$user$1$1 |
0%
(0/1)
|
0%
(0/2)
|
0%
(0/2)
|
0%
(0/31)
|
| ChatScreenKt$DisplayAllMessages$2$invoke$$inlined$items$default$1 |
0%
(0/1)
|
|
| ChatScreenKt$DisplayAllMessages$2$invoke$$inlined$items$default$2 |
0%
(0/1)
|
|
| ChatScreenKt$DisplayAllMessages$2$invoke$$inlined$items$default$3 |
0%
(0/1)
|
|
| ChatScreenKt$DisplayAllMessages$2$invoke$$inlined$items$default$4 |
0%
(0/1)
|
|
| ChatScreenKt$DisplayAllMessages$3 |
|
| ChatScreenKt$MessageBubble$1 |
0%
(0/1)
|
|
| ChatScreenKt$MessageBubble$2 |
|
| ChatScreenKt$UserStatusBar$1 |
0%
(0/1)
|
|
| ChatScreenKt$UserStatusBar$2 |
|
| Total |
0%
(0/30)
|
0%
(0/20)
|
0%
(0/27)
|
0%
(0/230)
|
// app/src/main/java/com/greybox/projectmesh/messaging/ui/screens/ChatScreen.kt
package com.greybox.projectmesh.messaging.ui.screens
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.SignalWifiOff
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSavedStateRegistryOwner
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import com.greybox.projectmesh.DeviceStatusManager
import com.greybox.projectmesh.GlobalApp
import com.greybox.projectmesh.ViewModelFactory
import com.greybox.projectmesh.messaging.data.entities.Message
import com.greybox.projectmesh.messaging.ui.models.ChatScreenModel
import com.greybox.projectmesh.messaging.ui.viewmodels.ChatScreenViewModel
import com.greybox.projectmesh.server.AppServer
import com.greybox.projectmesh.views.LongPressCopyableText
import com.greybox.projectmesh.testing.TestDeviceService
import kotlinx.coroutines.runBlocking
import org.kodein.di.compose.localDI
import java.net.InetAddress
import java.text.SimpleDateFormat
import java.util.Date
import android.provider.OpenableColumns
import org.kodein.di.instance
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
/**
* Composable function representing the main chat screen.
*
* @param virtualAddress The IP address of the chat participant.
* @param userName Optional username for the chat participant.
* @param isOffline Boolean flag indicating if the user is offline.
* @param onClickButton Callback for button click events.
* @param viewModel The [ChatScreenViewModel] providing UI state and actions.
*/
@Composable
fun ChatScreen(
virtualAddress: InetAddress,
userName: String? = null,
isOffline: Boolean = false,
onClickButton: () -> Unit,
viewModel: ChatScreenViewModel = viewModel(
factory = ViewModelFactory(
di = localDI(),
owner = LocalSavedStateRegistryOwner.current,
vmFactory = { di, savedStateHandle ->
ChatScreenViewModel(di, savedStateHandle)
},
defaultArgs = Bundle().apply {
putSerializable("virtualAddress", virtualAddress)
}
)
)
) {
//get user info
val userInfo = remember {
if (userName != null) {
//use provided userName if available
userName
} else {
//else try to look it up from the repository
runBlocking {
GlobalApp.GlobalUserRepo.userRepository.getUserByIp(virtualAddress.hostAddress)?.name
?: "Unknown User"
}
}
}
// Track device status from DeviceStatusManager
var deviceStatus by remember { mutableStateOf(false) }
// Only observe for real devices (not placeholders)
val shouldTrackStatus = remember {
virtualAddress.hostAddress != "0.0.0.0" &&
virtualAddress.hostAddress != TestDeviceService.TEST_DEVICE_IP_OFFLINE
}
if (shouldTrackStatus) {
// Collect directly from DeviceStatusManager
val statusMap by DeviceStatusManager.deviceStatusMap.collectAsState()
// Use LaunchedEffect with a key derived from the status map entry for this device
LaunchedEffect(statusMap[virtualAddress.hostAddress]) {
val newStatus = statusMap[virtualAddress.hostAddress] ?: false
if (deviceStatus != newStatus) {
Log.d(
"ChatScreen",
"Device status changed: ${virtualAddress.hostAddress} is now ${if (newStatus) "online" else "offline"}"
)
deviceStatus = newStatus
}
}
}
//Grab AppServer fromm local DI
val di = localDI()
val appServer: AppServer by di.instance()
val contextMessageForFile = LocalContext.current
// set up the system file-picker
val pickFileLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { selectedUri ->
// 1) start the transfer
appServer.addOutgoingTransfer(selectedUri, virtualAddress)
//Get name of file
var displayName = "unknown"
contextMessageForFile.contentResolver
.query(selectedUri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
.takeIf { it >= 0 }
?.let { idx ->
displayName = cursor.getString(idx)
}
}
}
//Send a chat message when file is sent via file transfer
viewModel.sendChatMessage(
virtualAddress,
"File $displayName was sent",
null
)
}
}
// declare the UI state, we can use the uiState to access the current state of the viewModel
val uiState: ChatScreenModel by viewModel.uiState.collectAsState(initial = ChatScreenModel())
var textMessage by rememberSaveable { mutableStateOf("") }
val context = LocalContext.current
val scrollState = rememberScrollState()
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier
.fillMaxSize()
.padding(bottom = 72.dp)) {
//add a status bar at the top of the chat
UserStatusBar(
userName = userInfo,
isOnline = deviceStatus,
userAddress = virtualAddress.hostAddress
)
// Show device status indicator
if (!deviceStatus) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
){
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
){
Icon(
imageVector = Icons.Default.SignalWifiOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "User is offline. Messages cannot be sent.",
color = MaterialTheme.colorScheme.error
)
}
}
}
DisplayAllMessages(uiState, onClickButton)
}
Row(modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(4.dp),
verticalAlignment = Alignment.Bottom //Change the Alignment So that it does not
// increase the height together with the TextField and be floating in the middle of the
// screen
) {
//File Picker Button
IconButton(
modifier = Modifier
.clip(CircleShape)
.border(2.dp, Color.Gray, CircleShape)
.size(40.dp),
onClick = { pickFileLauncher.launch("*/*") }
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Select a file to send"
)
}
TextField(
modifier = Modifier
.weight(3f)
.heightIn(min = 40.dp, max = 120.dp) // Added Min and Max Height to the
// TextField
.verticalScroll(scrollState),
value = textMessage,
onValueChange = {
textMessage = it
},
maxLines = 5,
enabled = deviceStatus // disable text input when user is offline
)
Button(modifier = Modifier.weight(1f), onClick = {
val message = textMessage.trimEnd()
//val imgpath = "sdcard/padorubastard.jpg"//test image path
//future implementation should implement file picker
//val filepath = Uri.parse(imgpath)
if(message.isNotEmpty()) {
viewModel.sendChatMessage(virtualAddress,message, null)
// resets the text field
textMessage = ""
}
},
enabled = deviceStatus //disable button when user is offline
) {
Text(text = "Send")
}
}
}
}
/**
* Composable function showing the user's status bar at the top of the chat.
*
* @param userName Name of the chat participant.
* @param isOnline Boolean flag indicating online/offline status.
* @param userAddress IP address of the chat participant.
*/
@Composable
fun UserStatusBar(
userName: String,
isOnline: Boolean,
userAddress: String
){
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = CardDefaults.cardColors(
containerColor = if (isOnline)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
//User avatar/icon
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (isOnline)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline
),
contentAlignment = Alignment.Center
) {
Text(
text = userName.first().toString(),
color = Color.White,
fontSize = 20.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
//User name and status
Column {
Text(
text = userName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
//Status indicator dot
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(
if (isOnline) Color.Green else Color.Gray
)
)
Spacer(modifier = Modifier.width(4.dp))
// Status text
Text(
text = if (isOnline) "Online" else "Offline",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
// IP address
Text(
text = userAddress,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp
)
}
}
}
}
}
/**
* Composable function displaying all messages in the chat.
*
* @param uiState [ChatScreenModel] representing the current state of the chat.
* @param onClickButton Callback for any button interactions within the messages list.
*/
@Composable
fun DisplayAllMessages(uiState: ChatScreenModel, onClickButton: () -> Unit) {
val context = LocalContext.current
//track if messages are showing:
val hasMessages = uiState.allChatMessages.isNotEmpty()
LaunchedEffect(uiState.allChatMessages.size) {
Log.d("ChatScreen", "DisplayAllMessages with ${uiState.allChatMessages.size} messages")
}
LazyColumn{
if (!hasMessages){
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No messages yet. Start a conversation!",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge
)
if (uiState.offlineWarning != null) {
Text(
text = "You're currently offline. Any messages you send will be delivered when connection is restored.",
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
items(
items = uiState.allChatMessages
){ chatMessage ->
Log.d("ChatDebug", "Rendering message: ${chatMessage.content}")
val sender: String = if (chatMessage.sender == "Me") {
"Me"
} else {
val ipStr = uiState.virtualAddress.hostAddress
val user = runBlocking {
GlobalApp.GlobalUserRepo.userRepository.getUserByIp(ipStr)
}
user?.name ?: chatMessage.sender
}
val sentBySelf = chatMessage.sender == "Me"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (sentBySelf) Arrangement.End else Arrangement.Start
) {
MessageBubble(
chatMessage = chatMessage,
sentBySelf = sentBySelf,
messageContent = {
LongPressCopyableText(
context = context,
text = "",
textCopyable = chatMessage.content,
textSize = 15
)
},
sender = sender,
modifier = Modifier
)
}
}
}
}
/**
* Composable function displaying an individual message bubble.
*
* @param chatMessage The [Message] object containing message data.
* @param sentBySelf Boolean indicating whether the message was sent by the current user.
* @param messageContent Composable lambda for rendering the message content.
* @param sender Name of the sender of the message.
* @param modifier Modifier to apply to the message bubble.
*/
@Composable
fun MessageBubble(
chatMessage: Message,
sentBySelf: Boolean,
messageContent: @Composable () -> Unit,
sender: String,
modifier: Modifier
){
val context = LocalContext.current
ElevatedCard(
colors = CardDefaults.cardColors(
containerColor = if(sentBySelf){
Color.Cyan
}else{
MaterialTheme.colorScheme.surfaceVariant
}
),
modifier = modifier
.padding(10.dp)
.widthIn(max = 280.dp)
) {
Column(modifier = Modifier.padding(4.dp)) {
Text(
text = sender,
style = MaterialTheme.typography.labelMedium
)
messageContent()
//ONLY SHOW FILE ATTACHMENT IF PRESENT
if (chatMessage.file != null) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
RoundedCornerShape(4.dp)
)
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "File",
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = chatMessage.file.toString(),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
// Adding timestamp to bottom right of message bubble
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Text(
text = SimpleDateFormat("HH:mm").format(Date(chatMessage.dateReceived)),
style = MaterialTheme.typography.labelSmall
)
}
}
}
}