package ui import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures 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.foundation.verticalScroll 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.PointerEventType import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import model.ConfigIndex import model.KisSession import network.KisTradeService import network.NewsService import network.StockUniverseLoader import service.AutoTradingManager import java.io.File import java.net.URI import kotlin.math.abs @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", "SETTING","ANALYZER","WATCH","AFTER","NOTICE")//"PASS",,"RETRY""HOLD", var llmAnalyser by remember { mutableStateOf(AutoTradingManager.llmAnalyser) } val tradeConfig by remember { CoroutineScope(Dispatchers.Default).launch { println("CALLED sendTelegramMessage -1") val now = java.time.LocalTime.now(java.time.ZoneId.of("Asia/Seoul")) NewsService.sendTelegramMessage("⏰ 자동 실행 시간(${now.hour}:${now.minute})입니다. 시스템을 가동합니다.") } mutableStateOf(KisSession.tradeConfig) } 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 filterScrollState = 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(1f).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), verticalAlignment = Alignment.CenterVertically ) { 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, scrollState = scrollState ) } } } 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) if (log.decision.contains("NOTICE") && log.reason.contains("[손절 경고]")) { Button( onClick = { // 종목 코드 추출 로직 (로그 메시지 형식에 따라 조절 필요) // 예: "[손절 경고] 삼성전자(005930) ..." 형식일 경우 val stockCode = log.stockName.substringAfter("[").substringBefore("]") if (stockCode.length == 6) { log?.stockCode?.let { code -> coroutineScope.launch { KisTradeService.postOrder(stockCode = code, qty= log.qty.toString(), price = "", isBuy = false).onSuccess { TradingLogStore.addSellLog( "${log.stockName}[${log.stockCode}]", "시장가", "SELL", "수동 손절 주문 완료" ) }.onFailure { error -> } } } } }, colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), modifier = Modifier.height(30.dp).padding(start = 8.dp) ) { Text("즉시 매도", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold) } } } } } } } Column(modifier = Modifier.weight(1f).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(20.dp)) Divider(color = Color.LightGray, thickness = 1.dp) Spacer(modifier = Modifier.height(10.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 = "목표 수익률 ${KisSession.config.SELL_PROFIT} 도달 시 기계적 매도" ) } 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(10.dp)) Divider(color = Color.LightGray, thickness = 1.dp) Spacer(modifier = Modifier.height(20.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 ) } } } } Column(modifier = Modifier.weight(1f).padding(8.dp).fillMaxHeight().background(Color.White).verticalScroll(rememberScrollState())) { Text("🕒 시간 및 매매 스케줄", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp)) Card(elevation = 2.dp, modifier = Modifier.fillMaxWidth().padding(4.dp)) { Column(modifier = Modifier.padding(12.dp)) { Text("자동 매매 시간 설정", fontWeight = FontWeight.Bold, color = Color.Blue) Spacer(Modifier.height(8.dp)) // 시작 시간 (HH:mm) TimeInputRow("자동 시작", tradeConfig.auto_start_time) { tradeConfig.auto_start_time = it KisSession.saveTradeConfig() } // 종료 시간 (HH:mm) TimeInputRow("자동 종료", tradeConfig.auto_end_time) { tradeConfig.auto_end_time = it KisSession.saveTradeConfig() } Divider(Modifier.padding(vertical = 8.dp)) // 매수 금지 시간 설정 TimeInputRow("매수 시작", tradeConfig.start_buy_time) { tradeConfig.start_buy_time = it KisSession.saveTradeConfig() } TimeInputRow("매수 종료", tradeConfig.end_buy_time) { tradeConfig.end_buy_time = it KisSession.saveTradeConfig() } } } Spacer(Modifier.height(16.dp)) Text("⚙️ 기타 고급 설정", style = MaterialTheme.typography.h6, modifier = Modifier.padding(8.dp)) Row(horizontalArrangement = Arrangement.SpaceEvenly) { SettingInputField( modifier = Modifier.weight(1.0f, true), label = "매수 분석 현 변동율 처저 기준", initialValue = (tradeConfig.minusFilter * -1).toString(), onSave = { tradeConfig.minusFilter = abs(it.toDouble()) KisSession.saveTradeConfig() }, helperText = "현제 변동율이 이것보다 커야 분석 함." ) SettingInputField( modifier = Modifier.weight(1.0f, true), label = "매수 분석 현 변동율 최고 기준", initialValue = (tradeConfig.plusFilter).toString(), onSave = { tradeConfig.plusFilter = it.toDouble() KisSession.saveTradeConfig() }, helperText = "현제 변동율이 이것보다 작아야 분석 함." ) } // Boolean 설정들 SettingSwitchField( label = "미체결 자동 취소 (매수)", initialChecked = tradeConfig.auto_cancel_pending_buy, helperText = "지정 시간 경과 시 미체결 매수 주문 취소", onCheckedChange = { tradeConfig.auto_cancel_pending_buy = it KisSession.saveTradeConfig() } ) SettingInputField( label = "자동 취소 대기 시간 (분)", initialValue = (tradeConfig.auto_cancel_pending_time / (60 * 1000)).toString(), onSave = { val minutes = it.toLongOrNull() ?: 60L tradeConfig.auto_cancel_pending_time = minutes * 60 * 1000 KisSession.saveTradeConfig() }, helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소" ) SettingInputField( label = "자동 취소 갭 비율", initialValue = (tradeConfig.auto_cancel_pending_rate).toString(), onSave = { val rate = it.toDoubleOrNull() ?: 2.0 tradeConfig.auto_cancel_pending_rate = rate KisSession.saveTradeConfig() }, helperText = "현재: ${tradeConfig.auto_cancel_pending_time / 1000}초 후 취소" ) SettingSwitchField( label = "장 전 대체마켓 매도", initialChecked = tradeConfig.before_nxt, onCheckedChange = { tradeConfig.before_nxt = it KisSession.saveTradeConfig() } ) SettingSwitchField( label = "장 후 대체 마켓 매도", initialChecked = tradeConfig.after_nxt, onCheckedChange = { tradeConfig.after_nxt = it KisSession.saveTradeConfig() } ) SettingSwitchField( label = "해외 주식", initialChecked = tradeConfig.enableOverSea, onCheckedChange = { tradeConfig.enableOverSea = it KisSession.saveTradeConfig() } ) SettingInputField( label = "특정 메시지를 수신하려면 텔레그램 아뒤 입력", initialValue = (tradeConfig.tlg_id).toString(), onSave = { tradeConfig.tlg_id = it KisSession.saveTradeConfig() }, helperText = "본인의 텔레그램 아뒤" ) SettingSwitchField( label = "물타기", initialChecked = tradeConfig.lowerAveragePrice, onCheckedChange = { tradeConfig.lowerAveragePrice = it KisSession.saveTradeConfig() } ) Row(horizontalArrangement = Arrangement.SpaceEvenly) { SettingInputField( modifier = Modifier.weight(1.0f, true), label = "물타기 최저선", initialValue = (tradeConfig.lowerAverageMaxRate).toString(), onSave = { tradeConfig.lowerAverageMaxRate = it.toDouble() KisSession.saveTradeConfig() }, helperText = "이것보다 커야 삼" ) SettingInputField( modifier = Modifier.weight(1.0f, true), label = "물타기 최고선", initialValue = (tradeConfig.lowerAverageMinRate).toString(), onSave = { tradeConfig.lowerAverageMinRate = it.toDouble() KisSession.saveTradeConfig() }, helperText = "이것보다 작아야 삼" ) } Row(horizontalArrangement = Arrangement.SpaceEvenly) { SettingInputField( modifier = Modifier.weight(1.0f, true), label = "물타기 기준 최소 보유 수량", initialValue = (tradeConfig.lowerAverageTargetCount).toString(), onSave = { tradeConfig.lowerAverageTargetCount = it.toInt() KisSession.saveTradeConfig() }, helperText = "이거 이상 갖고 있어야 삼" ) SettingInputField( modifier = Modifier.weight(1.0f, true), label = "물타기 개수", initialValue = (tradeConfig.lowerAverageStockCount).toString(), onSave = { tradeConfig.lowerAverageStockCount = it.toInt() KisSession.saveTradeConfig() }, helperText = "1만큼 사고 팜" ) } } } } @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) // ) // } // } //} @OptIn(ExperimentalMaterialApi::class) @Composable fun SettingInputField( modifier: Modifier? = null, label: String, initialValue: String, // 💡 value -> initialValue 로 변경 placeholder: String = "", helperText: String = "", onSave: (String) -> Unit // 💡 타자 칠 때마다가 아니라, 완료 시 저장하도록 콜백 변경 ) { // 💡 화면에 즉시 글자를 그려주기 위한 로컬 상태 (핵심 해결책) var localText by remember { mutableStateOf(initialValue) } Column(modifier = 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, ExperimentalComposeUiApi::class) @Composable fun FilterChipWithRightClick( label: String, isSelected: Boolean, onClick: () -> Unit, onClear: () -> Unit, scrollState: ScrollState // 스크롤 제어를 위해 추가 ) { var showMenu by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() var componentOffset by remember { mutableStateOf(0f) } var componentWidth by remember { mutableStateOf(0) } // 칩 너비 추가 Box( modifier = Modifier.onGloballyPositioned { coordinates -> componentOffset = coordinates.positionInParent().x componentWidth = coordinates.size.width // 칩의 실제 너비 저장 } ) { Surface( modifier = Modifier .padding(end = 6.dp) .pointerInput(label) { detectTapGestures( onTap = { onClick() coroutineScope.launch { // 부모(Row)의 전체 너비 가져오기 val containerWidth = scrollState.maxValue + scrollState.viewportSize val viewportWidth = scrollState.viewportSize // 🎯 중앙 정렬 공식: // (칩의 절대 위치) - (화면 절반) + (칩 너비의 절반) val targetScroll = (scrollState.value + componentOffset + (componentWidth / 2)) - (viewportWidth / 2) // 0과 최대 스크롤 범위 사이로 제한하여 부드럽게 이동 scrollState.animateScrollTo( targetScroll.toInt().coerceIn(0, scrollState.maxValue) ) } } ) } // 마우스 우클릭 별도 감지 .onPointerEvent(PointerEventType.Press) { event -> if (event.buttons.isSecondaryPressed) showMenu = true }, shape = RoundedCornerShape(8.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 = 6.dp, vertical = 6.dp), fontSize = 11.sp, color = if (isSelected) Color.White else Color.Black ) } DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { DropdownMenuItem(onClick = { onClear() showMenu = false }) { Text(if (label == "전체") "전체 로그 초기화" else "'$label' 삭제", color = Color.Red) } } } } /** * 시:분 입력을 위한 전용 컴포저블 */ @Composable fun TimeInputRow(label: String, initialTime: String, onTimeChange: (String) -> Unit) { // 💡 화면에 즉시 글자를 보여주기 위한 로컬 상태 var localText by remember(initialTime) { mutableStateOf(initialTime) } Row( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Text(label, modifier = Modifier.width(80.dp), fontSize = 13.sp, fontWeight = FontWeight.Medium) OutlinedTextField( value = localText, onValueChange = { // 5글자(HH:mm) 제한 및 입력 제어 if (it.length <= 5) { localText = it } }, placeholder = { Text("00:00") }, modifier = Modifier .weight(1f) .onFocusChanged { focusState -> // 💡 포커스를 잃었을 때(입력 완료 시) 실제 데이터 객체에 저장 if (!focusState.isFocused) { onTimeChange(localText) } }, singleLine = true, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, keyboardType = KeyboardType.Number // 숫자 키패드 유도 ), keyboardActions = KeyboardActions( onDone = { onTimeChange(localText) } ), textStyle = androidx.compose.ui.text.TextStyle(fontSize = 14.sp) ) } }