package com.bricks.mp.dev import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.automirrored.filled.ArrowRight import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.NetworkCheck import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.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 kotlinx.datetime.Clock import kotlinx.datetime.Instant /** * DevMode panel with tabs: Logs | Network | Errors. * Renders as a bottom sheet that can be toggled on/off. */ @Composable fun DevPanel( modifier: Modifier = Modifier, onClose: () -> Unit = {} ) { var selectedTab by remember { mutableIntStateOf(0) } val tabs = listOf("Logs", "Network", "Errors") val errorCount = DevLogStore.errorCount() val httpErrorCount = DevHttpInterceptor.errorCount() Surface( modifier = modifier.fillMaxSize(), color = Color(0xFF1E1E1E), tonalElevation = 8.dp ) { Column(modifier = Modifier.fillMaxSize()) { // Header bar Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Default.BugReport, contentDescription = null, tint = Color(0xFF4FC3F7), modifier = Modifier.width(20.dp).height(20.dp) ) Text( "Dev Mode", style = MaterialTheme.typography.titleSmall, color = Color(0xFFE0E0E0), modifier = Modifier.padding(start = 4.dp) ) } Row { IconButton(onClick = { DevLogStore.clear() DevHttpInterceptor.clear() }) { Icon( Icons.Default.Close, contentDescription = "Clear", tint = Color(0xFF9E9E9E), modifier = Modifier.width(18.dp).height(18.dp) ) } IconButton(onClick = onClose) { Icon( Icons.Default.Close, contentDescription = "Close", tint = Color(0xFF9E9E9E), modifier = Modifier.width(18.dp).height(18.dp) ) } } } // Tabs TabRow(selectedTabIndex = selectedTab) { tabs.forEachIndexed { index, label -> Tab( selected = selectedTab == index, onClick = { selectedTab = index }, text = { Row(verticalAlignment = Alignment.CenterVertically) { Text(label, fontSize = 12.sp) if (index == 2 && errorCount > 0) { Text( " ($errorCount)", fontSize = 10.sp, color = Color(0xFFD32F2F) ) } if (index == 1 && httpErrorCount > 0) { Text( " ($httpErrorCount)", fontSize = 10.sp, color = Color(0xFFD32F2F) ) } } } ) } } // Tab content when (selectedTab) { 0 -> LogsTab() 1 -> NetworkTab() 2 -> ErrorsTab() } } } } @Composable private fun LogsTab() { val entries by DevLogStore.entries.collectAsState(initial = emptyList()) val scrollState = rememberScrollState() if (entries.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text("No logs yet", color = Color(0xFF757575), fontSize = 14.sp) } return } Column( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) ) { entries.reversed().forEach { entry -> LogEntryRow(entry) } } } @Composable private fun LogEntryRow(entry: DevLogEntry) { var expanded by remember(entry.id) { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() .clickable { expanded = !expanded } .padding(horizontal = 8.dp, vertical = 2.dp) ) { // Summary row Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { Icon( imageVector = when (entry.level) { DevLogLevel.INFO -> Icons.Default.Info DevLogLevel.WARN -> Icons.Default.Warning DevLogLevel.ERROR -> Icons.Default.ErrorOutline DevLogLevel.EXCEPTION -> Icons.Default.ErrorOutline }, contentDescription = entry.level.label(), tint = Color(entry.level.badgeColor()), modifier = Modifier.width(14.dp).height(14.dp) ) Text( text = entry.timestamp.toDisplayTime(), color = Color(0xFF9E9E9E), fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(70.dp) ) // Source badge Text( text = entry.source.label, color = Color(0xFFBDBDBD), fontSize = 9.sp, fontFamily = FontFamily.Monospace, modifier = Modifier .background(Color(0xFF333333)) .padding(horizontal = 4.dp, vertical = 1.dp) .width(48.dp), maxLines = 1 ) // Level badge Text( text = entry.level.label(), color = Color(entry.level.badgeColor()), fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, modifier = Modifier .background(Color(0xFF222222)) .padding(horizontal = 4.dp, vertical = 1.dp) .width(36.dp), maxLines = 1 ) Text( text = entry.message, color = Color(0xFFE0E0E0), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) Icon( imageVector = if (expanded) Icons.Default.ArrowDropDown else Icons.AutoMirrored.Filled.ArrowRight, contentDescription = null, tint = Color(0xFF757575), modifier = Modifier.width(14.dp).height(14.dp) ) } // Expanded details if (expanded) { Column( modifier = Modifier .fillMaxWidth() .padding(start = 28.dp, top = 2.dp, bottom = 4.dp, end = 8.dp) ) { if (entry.details != null) { DevCodeBlock(entry.details) } if (entry.stackTrace != null) { DevCodeBlock(entry.stackTrace, color = Color(0xFFFF8A80)) } } } } } @Composable private fun NetworkTab() { val entries by DevHttpInterceptor.entries.collectAsState(initial = emptyList()) val scrollState = rememberScrollState() if (entries.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text("No HTTP requests yet", color = Color(0xFF757575), fontSize = 14.sp) } return } Column( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) ) { entries.reversed().forEach { entry -> HttpEntryRow(entry) } } } @Composable private fun HttpEntryRow(entry: DevHttpEntry) { var expanded by remember(entry.id) { mutableStateOf(false) } val statusColor = when { entry.isSuccess -> Color(0xFF4CAF50) entry.responseStatus != null && entry.responseStatus!! in 300..399 -> Color(0xFFFFA000) entry.responseStatus != null && entry.responseStatus!! >= 400 -> Color(0xFFD32F2F) else -> Color(0xFF9E9E9E) } Column( modifier = Modifier .fillMaxWidth() .clickable { expanded = !expanded } .padding(horizontal = 8.dp, vertical = 2.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { Icon( imageVector = Icons.Default.NetworkCheck, contentDescription = null, tint = statusColor, modifier = Modifier.width(14.dp).height(14.dp) ) Text( text = entry.timestamp.toDisplayTime(), color = Color(0xFF9E9E9E), fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(70.dp) ) // Method badge Text( text = entry.method, color = Color(0xFF4FC3F7), fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, modifier = Modifier .background(Color(0xFF222222)) .padding(horizontal = 4.dp, vertical = 1.dp) .width(40.dp), maxLines = 1 ) // Status badge val statusText = if (entry.error != null) "ERR" else "${entry.responseStatus ?: "..."}" Text( text = statusText, color = statusColor, fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, modifier = Modifier .background(Color(0xFF222222)) .padding(horizontal = 4.dp, vertical = 1.dp) .width(36.dp), maxLines = 1 ) // Duration if (entry.durationMs != null) { Text( text = "${entry.durationMs}ms", color = Color(0xFFBDBDBD), fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(60.dp) ) } Text( text = entry.url, color = Color(0xFFE0E0E0), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) Icon( imageVector = if (expanded) Icons.Default.ArrowDropDown else Icons.AutoMirrored.Filled.ArrowRight, contentDescription = null, tint = Color(0xFF757575), modifier = Modifier.width(14.dp).height(14.dp) ) } // Expanded details if (expanded) { Column( modifier = Modifier .fillMaxWidth() .padding(start = 28.dp, top = 2.dp, bottom = 4.dp, end = 8.dp) ) { Text( "URL: ${entry.url}", color = Color(0xFFBDBDBD), fontSize = 11.sp, modifier = Modifier.padding(vertical = 2.dp) ) if (entry.requestBody != null) { Text("Request Body:", color = Color(0xFF4FC3F7), fontSize = 11.sp, fontWeight = FontWeight.Medium) DevCodeBlock(entry.formatBody(entry.requestBody)) } if (entry.responseBody != null) { Text("Response Body:", color = Color(0xFF4CAF50), fontSize = 11.sp, fontWeight = FontWeight.Medium) DevCodeBlock(entry.formatBody(entry.responseBody)) } if (entry.error != null) { Text("Error:", color = Color(0xFFD32F2F), fontSize = 11.sp, fontWeight = FontWeight.Medium) DevCodeBlock(entry.error, color = Color(0xFFFF8A80)) } } } } } @Composable private fun ErrorsTab() { val entries by DevLogStore.logsByLevel(DevLogLevel.ERROR).collectAsState(initial = emptyList()) val scrollState = rememberScrollState() if (entries.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text("No errors", color = Color(0xFF4CAF50), fontSize = 14.sp) } return } Column( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) ) { entries.reversed().forEach { entry -> LogEntryRow(entry) } } } @Composable private fun DevCodeBlock( text: String, color: Color = Color(0xFFBDBDBD) ) { var copied by remember { mutableStateOf(false) } Card( modifier = Modifier .fillMaxWidth() .padding(vertical = 2.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFF111111)) ) { Box(modifier = Modifier.padding(6.dp)) { Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { IconButton( onClick = { // On desktop, we can't easily copy to clipboard without platform-specific code // So we just show a brief "copied" indicator copied = true }, modifier = Modifier.width(20.dp).height(20.dp) ) { Icon( Icons.Default.ContentCopy, contentDescription = "Copy", tint = if (copied) Color(0xFF4CAF50) else Color(0xFF757575), modifier = Modifier.width(12.dp).height(12.dp) ) } } Text( text = text, color = color, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) ) } } } // Reset copied state after delay if (copied) { androidx.compose.runtime.LaunchedEffect(Unit) { kotlinx.coroutines.delay(1500) copied = false } } }