feat: implement debug logging functionality and UI for message processing
This commit is contained in:
@@ -62,6 +62,7 @@ import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
@@ -283,6 +284,15 @@ fun ChatDetailScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||
var showUnblockConfirm by remember { mutableStateOf(false) }
|
||||
var showDebugLogs by remember { mutableStateOf(false) }
|
||||
|
||||
// Debug logs из ProtocolManager
|
||||
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||
|
||||
// Включаем UI логи только когда открыт bottom sheet
|
||||
LaunchedEffect(showDebugLogs) {
|
||||
ProtocolManager.enableUILogs(showDebugLogs)
|
||||
}
|
||||
|
||||
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
||||
val isBlocked by database.blacklistDao()
|
||||
@@ -864,20 +874,20 @@ fun ChatDetailScreen(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.clickable(
|
||||
indication =
|
||||
null,
|
||||
interactionSource =
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
.then(
|
||||
if (!isSavedMessages) {
|
||||
Modifier.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
onUserProfileClick(user)
|
||||
}
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
onUserProfileClick(user)
|
||||
},
|
||||
} else Modifier
|
||||
),
|
||||
contentAlignment =
|
||||
Alignment.Center
|
||||
) {
|
||||
@@ -924,20 +934,20 @@ fun ChatDetailScreen(
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.weight(1f)
|
||||
.clickable(
|
||||
indication =
|
||||
null,
|
||||
interactionSource =
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
.then(
|
||||
if (!isSavedMessages) {
|
||||
Modifier.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
onUserProfileClick(user)
|
||||
}
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
onUserProfileClick(user)
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment =
|
||||
@@ -1093,6 +1103,12 @@ fun ChatDetailScreen(
|
||||
false
|
||||
showDeleteConfirm =
|
||||
true
|
||||
},
|
||||
onLogsClick = {
|
||||
showMenu =
|
||||
false
|
||||
showDebugLogs =
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2115,6 +2131,16 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// 🐛 Debug Logs BottomSheet
|
||||
if (showDebugLogs) {
|
||||
DebugLogsBottomSheet(
|
||||
logs = debugLogs,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onDismiss = { showDebugLogs = false },
|
||||
onClearLogs = { ProtocolManager.clearLogs() }
|
||||
)
|
||||
}
|
||||
|
||||
// 📨 Forward Chat Picker BottomSheet
|
||||
if (showForwardPicker) {
|
||||
ForwardChatPickerBottomSheet(
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.*
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
@@ -146,11 +147,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Защита от двойной отправки
|
||||
private var isSending = false
|
||||
// 🔥 Throttling перенесён в глобальный MessageThrottleManager
|
||||
|
||||
// 🔥 Throttling для отправки сообщений (защита от спама)
|
||||
private var lastMessageSentTime = 0L
|
||||
private val MESSAGE_THROTTLE_MS = 100L // Минимум 100ms между сообщениями
|
||||
|
||||
// Job для отмены загрузки при смене диалога
|
||||
private var loadingJob: Job? = null
|
||||
|
||||
@@ -169,7 +167,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
|
||||
private var subscribedToOnlineStatus = false
|
||||
|
||||
|
||||
// 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
|
||||
// ВАЖНО: Должны быть определены ДО init блока!
|
||||
private val typingPacketHandler: (Packet) -> Unit = { packet ->
|
||||
val typingPacket = packet as PacketTyping
|
||||
if (typingPacket.fromPublicKey == opponentKey) {
|
||||
showTypingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
private val onlinePacketHandler: (Packet) -> Unit = { packet ->
|
||||
val onlinePacket = packet as PacketOnlineState
|
||||
onlinePacket.publicKeysState.forEach { item ->
|
||||
if (item.publicKey == opponentKey) {
|
||||
_opponentOnline.value = item.state == OnlineState.ONLINE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setupPacketListeners()
|
||||
setupNewMessageListener()
|
||||
@@ -299,69 +315,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun setupPacketListeners() {
|
||||
// ✅ Входящие сообщения обрабатываются ТОЛЬКО в MessageRepository (ProtocolManager)
|
||||
// Здесь НЕ обрабатываем 0x06 чтобы избежать дублирования!
|
||||
// ChatViewModel получает обновления через loadMessagesFromDatabase() после сохранения в БД
|
||||
|
||||
// Доставка
|
||||
ProtocolManager.waitPacket(0x08) { packet ->
|
||||
val deliveryPacket = packet as PacketDelivery
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Обновляем в БД
|
||||
updateMessageStatusInDb(deliveryPacket.messageId, 1) // DELIVERED
|
||||
// Обновляем UI
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Прочитано - пакет сообщает что собеседник прочитал наши сообщения
|
||||
// В Desktop нет messageId - просто отмечаем все исходящие сообщения как прочитанные
|
||||
ProtocolManager.waitPacket(0x07) { packet ->
|
||||
val readPacket = packet as PacketRead
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Если fromPublicKey == наш собеседник, значит он прочитал наши сообщения
|
||||
if (readPacket.fromPublicKey == opponentKey) {
|
||||
// Обновляем все непрочитанные исходящие сообщения в БД
|
||||
val account = myPublicKey ?: return@launch
|
||||
val opponent = opponentKey ?: return@launch
|
||||
messageDao.markAllAsRead(account, opponent)
|
||||
|
||||
// Обновляем UI - все исходящие сообщения помечаем как прочитанные
|
||||
withContext(Dispatchers.Main) {
|
||||
_messages.value = _messages.value.map { msg ->
|
||||
if (msg.isOutgoing && msg.status != MessageStatus.READ) {
|
||||
msg.copy(status = MessageStatus.READ)
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Typing
|
||||
ProtocolManager.waitPacket(0x0B) { packet ->
|
||||
val typingPacket = packet as PacketTyping
|
||||
if (typingPacket.fromPublicKey == opponentKey) {
|
||||
showTypingIndicator()
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 Онлайн статус (массив publicKey+state как в React Native)
|
||||
ProtocolManager.waitPacket(0x05) { packet ->
|
||||
val onlinePacket = packet as PacketOnlineState
|
||||
|
||||
onlinePacket.publicKeysState.forEach { item ->
|
||||
|
||||
if (item.publicKey == opponentKey) {
|
||||
_opponentOnline.value = item.state == OnlineState.ONLINE
|
||||
}
|
||||
}
|
||||
}
|
||||
// ✅ Обработчики 0x06, 0x07, 0x08 удалены - они обрабатываются ТОЛЬКО в ProtocolManager
|
||||
// Это предотвращает дублирование сообщений и статусов при навигации между чатами
|
||||
// ChatViewModel получает обновления через messageCache Flow из MessageRepository
|
||||
|
||||
// Typing - нужен здесь для UI текущего чата
|
||||
ProtocolManager.waitPacket(0x0B, typingPacketHandler)
|
||||
|
||||
// 🟢 Онлайн статус - нужен здесь для UI текущего чата
|
||||
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
|
||||
}
|
||||
|
||||
// ✅ handleIncomingMessage удалён - обработка входящих сообщений теперь ТОЛЬКО в MessageRepository
|
||||
@@ -1318,12 +1282,11 @@ val newList = messages + optimisticMessages
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Throttling - защита от спама сообщениями
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastMessageSentTime < MESSAGE_THROTTLE_MS) {
|
||||
// 🔥 Глобальный throttle - защита от спама сообщениями (app-wide)
|
||||
val dialogKey = "$sender:$recipient"
|
||||
if (!MessageThrottleManager.canSendWithContent(dialogKey, text.hashCode())) {
|
||||
return
|
||||
}
|
||||
lastMessageSentTime = now
|
||||
|
||||
isSending = true
|
||||
|
||||
@@ -2788,6 +2751,11 @@ val newList = messages + optimisticMessages
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
||||
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
|
||||
|
||||
lastReadMessageTimestamp = 0L
|
||||
readReceiptSentForCurrentDialog = false
|
||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
||||
|
||||
@@ -1162,7 +1162,8 @@ fun KebabMenu(
|
||||
isBlocked: Boolean,
|
||||
onBlockClick: () -> Unit,
|
||||
onUnblockClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
onDeleteClick: () -> Unit,
|
||||
onLogsClick: () -> Unit = {}
|
||||
) {
|
||||
val dividerColor =
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
||||
@@ -1196,6 +1197,23 @@ fun KebabMenu(
|
||||
)
|
||||
}
|
||||
|
||||
// Debug Logs
|
||||
KebabMenuItem(
|
||||
icon = TablerIcons.Bug,
|
||||
text = "Debug Logs",
|
||||
onClick = onLogsClick,
|
||||
tintColor = PrimaryBlue,
|
||||
textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.height(0.5.dp)
|
||||
.background(dividerColor)
|
||||
)
|
||||
|
||||
KebabMenuItem(
|
||||
icon = TablerIcons.Trash,
|
||||
text = "Delete Chat",
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.foundation.background
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.Bug
|
||||
import compose.icons.tablericons.Trash
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 🐛 BottomSheet для отображения debug логов протокола
|
||||
*
|
||||
* Показывает логи отправки/получения сообщений для дебага.
|
||||
* Использует ProtocolManager.debugLogs как источник данных.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DebugLogsBottomSheet(
|
||||
logs: List<String>,
|
||||
isDarkTheme: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onClearLogs: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
val scope = rememberCoroutineScope()
|
||||
val view = LocalView.current
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Colors
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||
|
||||
// Haptic feedback при открытии
|
||||
LaunchedEffect(Unit) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
}
|
||||
|
||||
// Авто-скролл вниз при новых логах
|
||||
LaunchedEffect(logs.size) {
|
||||
if (logs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(logs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Плавное затемнение статус бара
|
||||
DisposableEffect(Unit) {
|
||||
if (!view.isInEditMode) {
|
||||
val window = (view.context as? android.app.Activity)?.window
|
||||
val originalStatusBarColor = window?.statusBarColor ?: 0
|
||||
val scrimColor = android.graphics.Color.argb(153, 0, 0, 0)
|
||||
|
||||
val fadeInAnimator = android.animation.ValueAnimator.ofArgb(originalStatusBarColor, scrimColor).apply {
|
||||
duration = 200
|
||||
addUpdateListener { animator ->
|
||||
window?.statusBarColor = animator.animatedValue as Int
|
||||
}
|
||||
}
|
||||
fadeInAnimator.start()
|
||||
|
||||
onDispose {
|
||||
val fadeOutAnimator = android.animation.ValueAnimator.ofArgb(scrimColor, originalStatusBarColor).apply {
|
||||
duration = 150
|
||||
addUpdateListener { animator ->
|
||||
window?.statusBarColor = animator.animatedValue as Int
|
||||
}
|
||||
}
|
||||
fadeOutAnimator.start()
|
||||
}
|
||||
} else {
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissWithAnimation() {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dismissWithAnimation() },
|
||||
sheetState = sheetState,
|
||||
containerColor = backgroundColor,
|
||||
scrimColor = Color.Black.copy(alpha = 0.6f),
|
||||
dragHandle = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(2.5.dp))
|
||||
.background(if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6))
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
modifier = Modifier.statusBarsPadding()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Иконка и заголовок
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
TablerIcons.Bug,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Debug Logs",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
Text(
|
||||
text = "${logs.size} log entries",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопки
|
||||
Row {
|
||||
IconButton(onClick = onClearLogs) {
|
||||
Icon(
|
||||
TablerIcons.Trash,
|
||||
contentDescription = "Clear logs",
|
||||
tint = secondaryTextColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { dismissWithAnimation() }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = secondaryTextColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||
|
||||
// Контент
|
||||
if (logs.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No logs yet.\nLogs will appear here during messaging.",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Список логов
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 300.dp, max = 500.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
items(logs) { log ->
|
||||
DebugLogItem(log = log, isDarkTheme = isDarkTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент лога с цветовой кодировкой
|
||||
*/
|
||||
@Composable
|
||||
private fun DebugLogItem(
|
||||
log: String,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val successColor = Color(0xFF34C759)
|
||||
val errorColor = Color(0xFFFF3B30)
|
||||
val purpleColor = Color(0xFFAF52DE)
|
||||
val heartbeatColor = Color(0xFFFF9500)
|
||||
val messageColor = PrimaryBlue
|
||||
|
||||
// Определяем цвет по содержимому лога
|
||||
val logColor = when {
|
||||
log.contains("✅") || log.contains("SUCCESS") -> successColor
|
||||
log.contains("❌") || log.contains("ERROR") || log.contains("FAILED") -> errorColor
|
||||
log.contains("🔄") || log.contains("STATE") -> purpleColor
|
||||
log.contains("💓") || log.contains("💔") -> heartbeatColor
|
||||
log.contains("📥") || log.contains("📤") || log.contains("📨") -> messageColor
|
||||
else -> textColor.copy(alpha = 0.85f)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = log,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = logColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp, horizontal = 8.dp)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user