package ui import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.DragData import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.onExternalDrag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import model.ConfigIndex import model.KisSession import network.StockUniverseLoader import service.AutoTradingManager import java.io.File import java.net.URI @OptIn(ExperimentalMaterialApi::class) @Composable fun TradingDecisionLog() { val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() var searchQuery by remember { mutableStateOf("") } var selectedFilters by remember { mutableStateOf(setOf("전체")) } val filterOptions = listOf("전체", "BUY", "SELL", "HOLD", "SETTING","ANALYZER","PASS","WATCH","RETRY","AFTER","NOTICE") var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) } LaunchedEffect(AutoTradingManager.llmAnalyser) { llmAnalyser = AutoTradingManager.llmAnalyser } var llmNews by remember { mutableStateOf(AutoTradingManager.llmNews) } LaunchedEffect(AutoTradingManager.llmNews) { llmNews = AutoTradingManager.llmNews } var tradeToken by remember { mutableStateOf(AutoTradingManager.tradeToken) } LaunchedEffect(AutoTradingManager.tradeToken) { tradeToken = AutoTradingManager.tradeToken } var webSocketConnect by remember { mutableStateOf(AutoTradingManager.webSocketConnect) } LaunchedEffect(AutoTradingManager.webSocketConnect) { webSocketConnect = AutoTradingManager.webSocketConnect } val scrollState = rememberScrollState() // [핵심] 원본 로그에서 필터 조건에 맞는 리스트만 산출 val filteredLogs = TradingLogStore.decisionLogs.filter { log -> val matchesType = if (selectedFilters.contains("전체")) { true } else { selectedFilters.contains(log.decision) } val matchesQuery = log.stockName.contains(searchQuery, ignoreCase = true) || log.reason.contains(searchQuery, ignoreCase = true) matchesType && matchesQuery } Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) { Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) { Button( onClick = { coroutineScope.launch { // index 0으로 부드럽게 스크롤 (즉시 이동은 scrollToItem(0)) listState.animateScrollToItem(filteredLogs.size - 1) } } ) { Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6) } Row( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.Start ) { StatusIndicator("주식 분석기",llmAnalyser) StatusIndicator("뉴스 처리기",llmNews) StatusIndicator("한투 인증서",tradeToken) StatusIndicator("실시간 감시",webSocketConnect) } // [추가] 상단 검색 및 필터 UI Column(modifier = Modifier.padding(vertical = 8.dp)) { // 1. 검색창 OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, label = { Text("종목명 또는 내용 검색") }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, singleLine = true ) Spacer(modifier = Modifier.height(8.dp)) // 2. 필터 버튼 그룹 (Chip 형태) Row(modifier = Modifier .fillMaxWidth() .horizontalScroll(scrollState), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { filterOptions.forEach { option -> val isSelected = selectedFilters.contains(option) FilterChipWithRightClick( isSelected = isSelected, onClick = { val newFilters = selectedFilters.toMutableSet() if (option == "전체") { // "전체" 클릭 시 나머지는 다 지우고 전체만 남김 newFilters.clear() newFilters.add("전체") } else { // 다른 필터 클릭 시 newFilters.remove("전체") if (newFilters.contains(option)) { newFilters.remove(option) } else { newFilters.add(option) } // 만약 아무것도 선택 안 된 상태라면 다시 "전체" 선택 if (newFilters.isEmpty()) { newFilters.add("전체") } } selectedFilters = newFilters }, onClear = { if (option != "전체") { // 예: 로그의 등급(investmentGrade)이나 결정(decision)이 필터명과 일치할 때 삭제 TradingLogStore.decisionLogs.removeIf { log -> log.decision.contains(option) } } else { // "전체" 필터에서 우클릭 시 모든 로그 삭제 TradingLogStore.decisionLogs.clear() } // 💡 2. UI 동기화 (선택 해제) val newFilters = selectedFilters.toMutableSet() newFilters.remove(option) if (newFilters.isEmpty()) newFilters.add("전체") selectedFilters = newFilters }, label = option ) } } } Divider(Modifier.padding(bottom = 8.dp)) CsvDropZone( onUniverseUpdated = { updatedList -> // UI 갱신 (필요한 경우) // currentUniverse = updatedList // 💡 봇의 실제 작업 큐인 loadedTops 에도 갱신된 데이터를 덮어씌워 줌 AutoTradingManager.loadedTops.clear() AutoTradingManager.loadedTops.addAll(updatedList) AutoTradingManager.loadedTops.shuffle() // 섞어주면 편향 분석 방지! } ) Spacer(modifier = Modifier.height(16.dp)) // [수정] filteredLogs를 사용하여 최신 로그가 위로 오게 표시 LazyColumn( state = listState, reverseLayout = true) { items(filteredLogs) { log -> Card( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 2.dp ) { Column(modifier = Modifier.padding(12.dp)) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Text("${log.time} - ${log.stockName}", fontWeight = FontWeight.Bold) Text( text = log.decision, color = when (log.decision) { "BUY" -> Color.Red "SETTING" -> Color(0xFFFFA500) "SELL" -> Color(0xFF800080) "HOLD" -> Color.DarkGray "ANALYZER" -> Color.Green "PASS" -> Color.Yellow "RETRY" -> Color(0xFF00BCD4) // [추가] 하늘색 (재분석/대기열) "WATCH" -> Color(0xFF4CAF50) // [추가] 연초록 (관심 종목 감시) "AFTER" -> Color.Red "NOTICE" ->Color(0xFF1E88E5) else -> Color.DarkGray }, fontWeight = FontWeight.ExtraBold ) } when (log.decision) { "BUY", "SELL", "HOLD" -> Text("신뢰도: ${log.confidence}%", fontSize = 11.sp) } Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray) } } } } } Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) { LazyVerticalGrid( columns = GridCells.Fixed(6), // 💡 2와 3의 최소공배수인 6열로 통합! horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxSize().background(Color.White) // fillMaxWidth() 대신 전체 채우기 ) { // ========================================== // 1️⃣ 첫 번째 섹션: 2열 배치 구간 (span = 3) // ========================================== item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지 Text( "💰 거래 기본 설정", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) ) } var defaults = arrayOf( ConfigIndex.TAX_INDEX, ConfigIndex.PROFIT_INDEX, ConfigIndex.BUY_WEIGHT_INDEX, ConfigIndex.MAX_BUDGET_INDEX, ConfigIndex.MAX_PRICE_INDEX, ConfigIndex.MIN_PRICE_INDEX, ConfigIndex.MIN_PURCHASE_SCORE_INDEX, ConfigIndex.SELL_PROFIT, ConfigIndex.MAX_COUNT_INDEX, ConfigIndex.MAX_HOLDING_COUNT, ) // 💡 items에 span을 주어 3칸씩 차지하게 만듦 (결과적으로 2열 배치) items(defaults.size, span = { GridItemSpan(3) }) { index -> val configKey = defaults.get(index) var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } val saveAction = { var newValue = localText.toDoubleOrNull() ?: 0.0 var oldValue = KisSession.config.getValues(configKey) if (configKey.label.contains("PROFIT")) { newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) } KisSession.config.setValues(configKey, newValue) } var text = if (configKey.label.contains("PROFIT")) { "${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}" } else { localText } OutlinedTextField( value = text, onValueChange = { localText = it }, label = { Text(configKey.label) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal), keyboardActions = KeyboardActions(onDone = { saveAction() }), singleLine = true ) } // --- 🛡️ 수익 및 리스크 관리 섹션 --- item(span = { GridItemSpan(maxLineSpan) }) { Column { Spacer(modifier = Modifier.height(24.dp)) Divider(color = Color.LightGray, thickness = 1.dp) Spacer(modifier = Modifier.height(16.dp)) Text( text = "🛡️ 수익 및 리스크 관리", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.ExtraBold, color = Color.DarkGray, modifier = Modifier.padding(bottom = 12.dp) ) } } // 💡 2열 배치니까 각각 span = 3 item(span = { GridItemSpan(3) }) { SettingSwitchField( label = "자동 익절 활성화", initialChecked = KisSession.config.getValues(ConfigIndex.TAKE_PROFIT) > 0.0, onCheckedChange = { KisSession.config.setValues(ConfigIndex.TAKE_PROFIT, if (it) 1.0 else 0.0) }, helperText = "목표 수익률 도달 시 기계적 익절" ) } item(span = { GridItemSpan(3) }) { SettingSwitchField( label = "자동 손절 활성화", initialChecked = KisSession.config.getValues(ConfigIndex.STOP_LOSS) > 0.0, onCheckedChange = { KisSession.config.setValues(ConfigIndex.STOP_LOSS, if (it) 1.0 else 0.0) }, helperText = "손실 방어선 도달 시 기계적 손절" ) } item(span = { GridItemSpan(3) }) { SettingInputField( label = "최소 손절 라인 (%)", initialValue = KisSession.config.getValues(ConfigIndex.LOSS_MINRATE).toString(), placeholder = "-1.5", onSave = { KisSession.config.setValues(ConfigIndex.LOSS_MINRATE, it.toDoubleOrNull() ?: -1.5) }, helperText = "타협 가능한 최소 라인" ) } item(span = { GridItemSpan(3) }) { SettingInputField( label = "최대 허용 손절률 (%)", initialValue = KisSession.config.getValues(ConfigIndex.LOSS_MAXRATE).toString(), placeholder = "-5.0", onSave = { KisSession.config.setValues(ConfigIndex.LOSS_MAXRATE, it.toDoubleOrNull() ?: -5.0) }, helperText = "절대 방어선 (기계적 매도)" ) } // 💡 금액은 길게 뻗어야 하니 maxLineSpan item(span = { GridItemSpan(maxLineSpan) }) { SettingInputField( label = "최대 허용 손실 금액 (원)", initialValue = KisSession.config.getValues(ConfigIndex.LOSS_MAX_MONEY).toLong().toString(), placeholder = "50000", onSave = { KisSession.config.setValues(ConfigIndex.LOSS_MAX_MONEY, it.toDoubleOrNull() ?: 0.0) }, helperText = "1종목당 허용할 수 있는 최대 손실액 (원 단위)" ) } item(span = { GridItemSpan(maxLineSpan) }) { Column { Spacer(modifier = Modifier.height(16.dp)) Divider(color = Color.LightGray, thickness = 1.dp) Spacer(modifier = Modifier.height(24.dp)) } } // ========================================== // 2️⃣ 두 번째 섹션: 3열 배치 구간 (span = 2) // ========================================== item(span = { GridItemSpan(maxLineSpan) }) { // 6칸 모두 차지 Text( "💰 매수 정책 및 기대 수익률", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) ) } var defaults2 = arrayOf( arrayOf(ConfigIndex.GRADE_5_BUY, ConfigIndex.GRADE_5_PROFIT, ConfigIndex.GRADE_5_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_4_BUY, ConfigIndex.GRADE_4_PROFIT, ConfigIndex.GRADE_4_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_3_BUY, ConfigIndex.GRADE_3_PROFIT, ConfigIndex.GRADE_3_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_2_BUY, ConfigIndex.GRADE_2_PROFIT, ConfigIndex.GRADE_2_ALLOCATIONRATE), arrayOf(ConfigIndex.GRADE_1_BUY, ConfigIndex.GRADE_1_PROFIT, ConfigIndex.GRADE_1_ALLOCATIONRATE), ) for (items in defaults2) { val common = findLongestCommonSubstring(items.first().label, items.last().label) item(span = { GridItemSpan(maxLineSpan) }) { Text( common, style = MaterialTheme.typography.body1, modifier = Modifier.padding(top = 10.dp, bottom = 8.dp) ) } // 💡 items에 span을 주어 2칸씩 차지하게 만듦 (결과적으로 3열 배치) items(items.size, span = { GridItemSpan(2) }) { index -> val configKey = items.get(index) var localText by remember(configKey) { mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "") } var labelText by remember(configKey) { mutableStateOf("") } val saveAction = { var oldValue = KisSession.config.getValues(configKey) var newValue = localText.toDoubleOrNull() ?: 0.0 KisSession.config.setValues(configKey, newValue) } // labelText 업데이트 로직 (기존과 동일) labelText = if (configKey.name.contains("PROFIT")) { getRemaining(configKey.label, common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 비율(${KisSession.config.getValues(configKey)}) + 세금(${KisSession.config.getValues(ConfigIndex.TAX_INDEX)}) = ${(localText.toDoubleOrNull() ?: 0.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)}" } else if (configKey.name.contains("ALLOCATIONRATE")) { getRemaining(configKey.label, common) + ": 최대 ${KisSession.config.getValues(ConfigIndex.MAX_BUDGET_INDEX) * (localText.toDoubleOrNull() ?: 0.0)}원" } else { getRemaining(configKey.label, common) + ": -${localText} 호가 매수" } OutlinedTextField( value = localText, onValueChange = { localText = it }, label = { Text(labelText) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> if (!focusState.isFocused) saveAction() }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal), keyboardActions = KeyboardActions(onDone = { saveAction() }), singleLine = true ) } } } } } } @Composable fun StatusIndicator(label: String, isActive: Boolean, onRestart: (() -> Unit)? = null) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp) ) { Text(text = label, style = MaterialTheme.typography.body2) Spacer(Modifier.width(4.dp)) // 상태 아이콘 (초록 O / 빨간 X) Text( text = if (isActive) "●" else "×", color = if (isActive) Color.Green else Color.Red, fontSize = 18.sp, fontWeight = FontWeight.Bold ) // 문제가 있을 때(false)만 재구동 유도 버튼 표시 // if (!isActive && onRestart != null) { // TextButton(onClick = onRestart, contentPadding = PaddingValues(0.dp)) { // Text("재구동", color = Color.Blue, fontSize = 11.sp, textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline) // } // } } } fun findLongestCommonSubstring(s1: String, s2: String): String { if (s1.isEmpty() || s2.isEmpty()) return "" var longest = "" // 더 짧은 문자열을 기준으로 삼아 반복 횟수를 줄임 val reference = if (s1.length <= s2.length) s1 else s2 val target = if (s1.length <= s2.length) s2 else s1 for (i in reference.indices) { for (j in (i + longest.length + 1)..reference.length) { val sub = reference.substring(i, j) if (target.contains(sub)) { if (sub.length > longest.length) { longest = sub } } else { // target에 포함되지 않으면 더 긴 substring은 존재할 수 없으므로 탈출 break } } } return longest } fun getRemaining(original: String, common: String): String { if (common.isEmpty()) return original // 가장 처음 발견되는 공통 문자열을 한 번만 제거 return original.replaceFirst(common, "").trim() } fun parseSmartCsv(file: File): List> { val result = mutableListOf>() try { val lines = file.readLines() if (lines.isEmpty()) return result // 1. 헤더 분석하여 열(Column) 인덱스 자동 추적 val headers = lines[0].split(",").map { it.replace("\"", "").trim() } val codeIndex = headers.indexOfFirst { it.contains("종목코드") } val nameIndex = headers.indexOfFirst { it.contains("종목명") } // 헤더를 못 찾았다면 기본값 (0번: 코드, 1번: 이름)으로 폴백 val finalCodeIdx = if (codeIndex != -1) codeIndex else 0 val finalNameIdx = if (nameIndex != -1) nameIndex else 1 // 2. 데이터 추출 for (i in 1 until lines.size) { val line = lines[i] if (line.isBlank()) continue val parts = line.split(",").map { it.replace("\"", "").trim() } if (parts.size > maxOf(finalCodeIdx, finalNameIdx)) { val code = parts[finalCodeIdx] val name = parts[finalNameIdx] // 6자리 숫자로 된 정상적인 종목코드인지 검증 if (code.length == 6 && code.all { it.isDigit() }) { result.add(code to name) } } } } catch (e: Exception) { println("❌ CSV 파싱 에러: ${e.message}") } return result } @OptIn(ExperimentalComposeUiApi::class) @Composable fun CsvDropZone( onUniverseUpdated: (List>) -> Unit ) { var isDragging by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxWidth() .height(40.dp) .background(if (isDragging) Color(0xFFE3F2FD) else Color(0xFFFAFAFA)) .border( width = 1.dp, color = if (isDragging) Color.Blue else Color.LightGray, shape = RoundedCornerShape(8.dp) ) .onExternalDrag( onDragStart = { isDragging = true }, onDragExit = { isDragging = false }, onDrop = { state -> isDragging = false val dragData = state.dragData if (dragData is DragData.FilesList) { val fileUris = dragData.readFiles() fileUris.firstOrNull { it.endsWith(".csv", ignoreCase = true) }?.let { uri -> // 💡 여기서 깔끔하게 StockUniverseLoader 에 처리를 위임! val file = File(URI(uri)) val updatedUniverse = StockUniverseLoader.parseAndMergeCsv(file) onUniverseUpdated(updatedUniverse) } } } ), contentAlignment = Alignment.Center ) { Text( text = if (isDragging) "📥 파일을 놓아서 업데이트!" else "📁 [CSV 추가] 파일을 드래그하여 유니버스 자동 병합", color = if (isDragging) Color.Blue else Color.Gray, fontSize = 12.sp, fontWeight = FontWeight.SemiBold ) } } @OptIn(ExperimentalMaterialApi::class) @Composable fun SettingInputField( label: String, initialValue: String, // 💡 value -> initialValue 로 변경 placeholder: String = "", helperText: String = "", onSave: (String) -> Unit // 💡 타자 칠 때마다가 아니라, 완료 시 저장하도록 콜백 변경 ) { // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책) var localText by remember { mutableStateOf(initialValue) } Column(modifier = Modifier.fillMaxWidth()) { OutlinedTextField( value = localText, onValueChange = { localText = it }, // 타자 칠 때 화면 즉시 반영 label = { Text(label, fontWeight = FontWeight.Bold) }, placeholder = { Text(placeholder) }, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> // 💡 포커스를 잃었을 때 (다른 칸을 클릭했을 때) 저장 if (!focusState.isFocused) { onSave(localText) } }, singleLine = true, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal ), keyboardActions = KeyboardActions( // 💡 모바일 키보드나 키보드에서 엔터(Done) 쳤을 때 저장 onDone = { onSave(localText) } ) ) if (helperText.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( text = helperText, color = Color.Gray, fontSize = 11.sp, modifier = Modifier.padding(start = 4.dp) ) } } } @Composable fun SettingSwitchField( label: String, initialChecked: Boolean, helperText: String = "", onCheckedChange: (Boolean) -> Unit ) { // 💡 스위치 애니메이션을 즉각 보여주기 위한 로컬 상태 var localChecked by remember { mutableStateOf(initialChecked) } Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp, horizontal = 4.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( text = label, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) Switch( checked = localChecked, onCheckedChange = { isChecked -> localChecked = isChecked // 화면 스위치 즉시 변경 onCheckedChange(isChecked) // 실제 DB/설정 저장 트리거 } ) } if (helperText.isNotEmpty()) { Text( text = helperText, color = Color.Gray, fontSize = 11.sp, modifier = Modifier.padding(start = 2.dp) ) } } } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) @Composable fun FilterChipWithRightClick( label: String, isSelected: Boolean, onClick: () -> Unit, onClear: () -> Unit ) { var showMenu by remember { mutableStateOf(false) } Box { Surface( modifier = Modifier .padding(end = 6.dp) .pointerInput(label) { awaitEachGesture { val down = awaitFirstDown() // 💡 마우스 우클릭 검사 if (down.type == PointerType.Mouse && currentEvent.buttons.isSecondaryPressed) { down.consume() showMenu = true } else { // 💡 터치/좌클릭 시 롱클릭 판별 val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() } if (up == null) { showMenu = true // 롱클릭 } else { up.consume() onClick() // 일반 클릭 } } } }, shape = RoundedCornerShape(16.dp), color = if (isSelected) Color(0xFF0E62CF) else Color(0xFFF0F2F5), border = BorderStroke(1.dp, if (isSelected) Color(0xFF0E62CF) else Color.LightGray) ) { Text( text = label, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), fontSize = 11.sp, color = if (isSelected) Color.White else Color.Black, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal ) } // 💡 우클릭 시 나타나는 메뉴 DropdownMenu( expanded = showMenu, onDismissRequest = { showMenu = false } ) { DropdownMenuItem(onClick = { onClear() // 실제 삭제 수행 showMenu = false }) { val menuText = if (label == "전체") "전체 로그 초기화" else "'$label' 관련 로그 삭제" Text(menuText, color = Color.Red, fontSize = 12.sp) } } } }