atrade/src/main/kotlin/ui/DashboardScreen.kt

514 lines
23 KiB
Kotlin
Raw Normal View History

2026-01-13 16:04:25 +09:00
// src/main/kotlin/ui/DashboardScreen.kt
2026-01-10 18:16:50 +09:00
package ui
2026-01-14 15:42:26 +09:00
import AutoTradeItem
2026-01-23 17:05:09 +09:00
import TradingDecision
2026-01-10 18:16:50 +09:00
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
2026-02-19 15:47:31 +09:00
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
2026-01-10 18:16:50 +09:00
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
2026-02-19 15:47:31 +09:00
import androidx.compose.ui.focus.onFocusChanged
2026-01-10 18:16:50 +09:00
import androidx.compose.ui.graphics.Color
2026-02-19 15:47:31 +09:00
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
2026-01-10 18:16:50 +09:00
import androidx.compose.ui.unit.dp
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.CoroutineScope
2026-02-19 16:20:15 +09:00
import kotlinx.coroutines.CoroutineStart
2026-02-06 17:53:17 +09:00
import kotlinx.coroutines.Dispatchers
2026-02-19 16:20:15 +09:00
import kotlinx.coroutines.delay
2026-01-14 15:42:26 +09:00
import kotlinx.coroutines.launch
2026-02-04 14:52:09 +09:00
import model.CandleData
2026-02-19 15:47:31 +09:00
import model.ConfigIndex
2026-01-21 11:49:30 +09:00
import model.ExecutionData
2026-01-13 16:04:25 +09:00
import model.KisSession
2026-01-19 17:09:37 +09:00
import model.StockBasicInfo
2026-01-10 18:16:50 +09:00
import network.KisTradeService
import network.KisWebSocketManager
2026-02-03 18:07:18 +09:00
import service.AutoTradingManager
2026-02-04 14:52:09 +09:00
import service.TechnicalAnalyzer
2026-02-06 17:53:17 +09:00
import service.TradingDecisionCallback
2026-02-04 14:52:09 +09:00
import util.MarketUtil
import kotlin.collections.mutableListOf
2026-01-10 18:16:50 +09:00
@Composable
2026-01-13 16:04:25 +09:00
fun DashboardScreen() {
2026-01-22 16:21:18 +09:00
val tradeService = remember { KisTradeService }
2026-01-13 16:04:25 +09:00
val wsManager = remember { KisWebSocketManager() }
2026-01-14 15:42:26 +09:00
val scope = rememberCoroutineScope()
2026-01-10 18:16:50 +09:00
var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") }
2026-01-13 16:04:25 +09:00
var isDomestic by remember { mutableStateOf(true) }
2026-01-19 17:09:37 +09:00
var selectedStockQuantity by remember { mutableStateOf("0") }
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
2026-01-23 17:05:09 +09:00
var completeTradingDecision by remember { mutableStateOf<TradingDecision?>(null) } // 단순 종목 선택 시
2026-02-04 14:52:09 +09:00
var min30 by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var daySummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var weekSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
2026-02-06 17:53:17 +09:00
fun setupAutoTradingWatchdog(tradeService: KisTradeService, callback: TradingDecisionCallback) {
CoroutineScope(Dispatchers.Default).launch {
// while (true) {
// delay(60000) // 1분마다 체크
2026-02-19 15:47:31 +09:00
AutoTradingManager.checkAndRestart(tradeService, callback)
2026-02-06 17:53:17 +09:00
// }
}
}
var callback = object : TradingDecisionCallback {
override fun invoke(decision: TradingDecision?, isSuccess: Boolean) {
2026-02-04 14:52:09 +09:00
if (!isSuccess && decision?.confidence ?: 0.0 < 0.0) {
decision?.stockCode?.let { stockCode ->
decision?.stockName?.let { stockName ->
selectedStockCode = stockCode
selectedStockName = stockName
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
}
}
2026-02-03 18:07:18 +09:00
2026-02-04 14:52:09 +09:00
}else if (isSuccess && decision != null) {
if (!selectedStockCode.equals(decision.stockCode) && selectedStockName.equals(decision.stockName)) {
selectedStockCode = decision.stockCode
selectedStockName = decision.stockName
isDomestic = true // 발굴 로직은 국내주식 기준이므로 true 고정
}
2026-02-03 18:07:18 +09:00
// 2. 결정 객체 업데이트 -> IntegratedOrderSection의 LaunchedEffect 트리거
completeTradingDecision = decision
}
}
2026-02-06 17:53:17 +09:00
}
2026-02-19 16:20:15 +09:00
LaunchedEffect(Unit) {
// 화면이 완전히 그려지고 안정화될 때까지 1초 대기
delay(1000)
AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback)
}
2026-02-03 18:07:18 +09:00
2026-02-19 16:20:15 +09:00
// 리소스 정리는 여전히 DisposableEffect에서 수행
DisposableEffect(Unit) {
2026-02-03 18:07:18 +09:00
onDispose {
AutoTradingManager.stopDiscovery()
}
}
2026-01-19 17:09:37 +09:00
2026-01-21 11:49:30 +09:00
// 중앙 관리용 상태들
var refreshTrigger by remember { mutableStateOf(0) }
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
val executionCache = remember { mutableMapOf<String, ExecutionData>() }
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
2026-02-06 17:53:17 +09:00
LaunchedEffect(refreshTrigger) {
2026-02-19 16:20:15 +09:00
// setupAutoTradingWatchdog(tradeService,callback)
2026-02-06 17:53:17 +09:00
}
2026-01-21 11:49:30 +09:00
suspend fun syncAndExecute(orderNo: String) {
if (processingIds.contains(orderNo)) return
processingIds.add(orderNo)
try {
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
val execData = executionCache[orderNo]
2026-01-19 17:09:37 +09:00
2026-01-21 11:49:30 +09:00
if (dbItem != null && execData != null && execData.isFilled) {
if (dbItem.status == TradeStatus.PENDING_BUY) {
2026-02-04 14:52:09 +09:00
// 1. 실제 매수 체결가 가져오기 (문자열인 경우 숫자로 변환)
val actualBuyPrice = execData.price.toDoubleOrNull() ?: dbItem.targetPrice
// 2. 최소 마진 설정 (수수료/세금 0.3% + 순수익 1.5% = 1.8%)
2026-02-19 15:47:31 +09:00
val minEffectiveRate = KisSession.config.getValues(ConfigIndex.PROFIT_INDEX) + KisSession.config.getValues(ConfigIndex.TAX_INDEX)
2026-02-04 14:52:09 +09:00
// 3. DB에 설정된 목표 수익률과 최소 보장 수익률 중 큰 값 선택
val finalProfitRate = maxOf(dbItem.profitRate, minEffectiveRate)
// 4. 실제 체결가 기준 익절 가격 재계산 및 틱 사이즈 보정
val finalTargetPrice = MarketUtil.roundToTickSize(actualBuyPrice * (1 + finalProfitRate / 100.0))
println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} | 매수가: ${actualBuyPrice.toInt()} -> 목표가: ${finalTargetPrice.toInt()} (${String.format("%.2f", finalProfitRate)}% 적용)")
2026-01-21 11:49:30 +09:00
tradeService.postOrder(
stockCode = dbItem.code,
qty = dbItem.quantity.toString(),
2026-02-04 14:52:09 +09:00
price = finalTargetPrice.toLong().toString(),
2026-01-21 11:49:30 +09:00
isBuy = false
).onSuccess { newSellOrderNo ->
2026-02-04 14:52:09 +09:00
// 익절가 업데이트 및 상태 변경
2026-01-21 11:49:30 +09:00
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo)
2026-02-04 14:52:09 +09:00
// (선택 사항) 실제 계산된 익절가를 DB에 기록하고 싶다면 별도 update 로직 추가 가능
2026-01-21 11:49:30 +09:00
executionCache.remove(orderNo)
refreshTrigger++
}.onFailure {
println("❌ 익절 주문 실패: ${it.message}")
}
} else if (dbItem.status == TradeStatus.SELLING) {
println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}")
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
executionCache.remove(orderNo)
refreshTrigger++
}
}
} finally {
processingIds.remove(orderNo)
}
}
2026-01-10 18:16:50 +09:00
LaunchedEffect(Unit) {
2026-01-14 15:42:26 +09:00
// 1. 웹소켓 연결
2026-01-13 16:04:25 +09:00
wsManager.connect()
2026-01-14 15:42:26 +09:00
2026-01-19 17:09:37 +09:00
// 2. [기동 시 동기화 시나리오]
scope.launch {
// (1) 서버 미체결 내역 로드
val serverOrders = tradeService.fetchUnfilledOrders().getOrDefault(emptyList())
val serverOrderNos = serverOrders.map { it.ord_no }
// (2) DB 상태 대조 및 EXPIRED 전환
DatabaseFactory.syncWithServer(serverOrderNos)
// (3) 활성 감시 종목 구독 재개
val monitoringTrades = DatabaseFactory.getAutoTradesByStatus(listOf(TradeStatus.MONITORING, TradeStatus.PENDING_BUY))
val monitoringCodes = monitoringTrades.map { it.code }.toSet()
wsManager.updateSubscriptions(monitoringCodes)
2026-02-19 16:20:15 +09:00
2026-01-14 15:42:26 +09:00
refreshTrigger++
}
2026-02-19 16:20:15 +09:00
2026-01-19 17:09:37 +09:00
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
2026-01-21 11:49:30 +09:00
wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy ->
2026-01-14 15:42:26 +09:00
scope.launch {
2026-01-21 11:49:30 +09:00
val exec = ExecutionData(orderNo, code, price, qty, isBuy)
executionCache[orderNo] = exec
syncAndExecute(orderNo)
2026-01-14 15:42:26 +09:00
}
}
2026-01-10 18:16:50 +09:00
}
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
2026-01-13 16:04:25 +09:00
// [좌측 25%] 내 자산 및 통합 잔고
2026-02-19 15:47:31 +09:00
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
2026-01-19 17:09:37 +09:00
BalanceSection(tradeService,
onRefresh = { refreshTrigger++ },
refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
2026-01-10 18:16:50 +09:00
selectedStockCode = code
selectedStockName = name
2026-01-13 16:04:25 +09:00
isDomestic = isDom
2026-01-19 17:09:37 +09:00
selectedStockQuantity = qty
2026-01-13 16:04:25 +09:00
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
2026-01-10 18:16:50 +09:00
}
}
VerticalDivider()
2026-01-13 16:04:25 +09:00
// [중앙 45%] 실시간 정보 및 주문
2026-01-22 16:21:18 +09:00
Column(modifier = Modifier.weight(0.40f).fillMaxHeight().background(Color.White)) {
2026-01-10 18:16:50 +09:00
if (selectedStockCode.isNotEmpty()) {
2026-01-13 16:04:25 +09:00
StockDetailSection(
2026-02-04 14:52:09 +09:00
min30 = min30,
daySummary = daySummary,
monthSummary = monthSummary,
weekSummary = weekSummary,
yearSummary = yearSummary,
2026-01-13 16:04:25 +09:00
stockCode = selectedStockCode,
stockName = selectedStockName,
2026-01-19 17:09:37 +09:00
holdingQuantity = selectedStockQuantity,
2026-01-13 16:04:25 +09:00
isDomestic = isDomestic,
tradeService = tradeService,
2026-01-21 11:49:30 +09:00
wsManager = wsManager,
onOrderSaved = { orderNo ->
scope.launch {
syncAndExecute(orderNo) // 매칭 시도
}
2026-01-23 17:05:09 +09:00
},
2026-02-04 14:52:09 +09:00
completeTradingDecision = completeTradingDecision,
2026-01-13 16:04:25 +09:00
)
2026-01-10 18:16:50 +09:00
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
2026-01-13 16:04:25 +09:00
Text("분석할 종목을 선택하세요", color = Color.Gray)
2026-01-10 18:16:50 +09:00
}
}
}
VerticalDivider()
2026-02-19 15:47:31 +09:00
Column(modifier = Modifier.weight(0.25f).padding(8.dp).fillMaxHeight().background(Color.White)) {
2026-01-22 16:21:18 +09:00
AiAnalysisView(
2026-02-04 14:52:09 +09:00
technicalAnalyzer = TechnicalAnalyzer().apply {
this.min30 = min30
this.daily = daySummary
this.weekly = weekSummary
this.monthly = monthSummary
this.weekly = weekSummary
},
2026-01-22 16:21:18 +09:00
stockCode = selectedStockCode,
stockName = selectedStockName,
2026-02-12 15:31:34 +09:00
currentPrice = "0",
2026-01-23 17:05:09 +09:00
trades = wsManager.tradeLogs,
tradingDecisionCallback = { decision,bool ->
if (bool && decision != null && KisSession.config.isSimulation) {
completeTradingDecision = decision
}
}
2026-01-22 16:21:18 +09:00
)
2026-02-19 15:47:31 +09:00
Spacer(modifier = Modifier.height(16.dp))
Text("설정값 관리", style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 4.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2), // 2열 병렬 배치
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().weight(0.3f)
) {
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰 거래 기본 설정",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.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.MAX_COUNT_INDEX,
)
items(defaults.size) { index ->
val configKey = defaults.get(index)
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
// 저장 로직을 공통 함수로 분리
val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0
if (configKey.label.contains("PROFIT")) {
newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
}
KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $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 ->
// 2. 포커스를 잃었을 때 저장
if (!focusState.isFocused) {
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true
)
}
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰매수 정책 및 기대 수익률",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
var defaults2 = arrayOf(
arrayOf(ConfigIndex.GRADE_5_BUY,
ConfigIndex.GRADE_5_PROFIT,),
arrayOf(ConfigIndex.GRADE_4_BUY,
ConfigIndex.GRADE_4_PROFIT,),
arrayOf(ConfigIndex.GRADE_3_BUY,
ConfigIndex.GRADE_3_PROFIT,),
arrayOf(ConfigIndex.GRADE_2_BUY,
ConfigIndex.GRADE_2_PROFIT,),
arrayOf(ConfigIndex.GRADE_1_BUY,
ConfigIndex.GRADE_1_PROFIT,),
)
for (items in defaults2) {
val common = findLongestCommonSubstring(items.first().label,items.last().label)
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
common,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
}
2026-02-19 16:20:15 +09:00
items(items.size) { index ->
val configKey = items.get(index)
2026-02-19 15:47:31 +09:00
2026-02-19 16:20:15 +09:00
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
2026-02-19 15:47:31 +09:00
2026-02-19 16:20:15 +09:00
var labelText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
2026-02-19 15:47:31 +09:00
2026-02-19 16:20:15 +09:00
val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0
2026-02-19 15:47:31 +09:00
// if (configKey.name.contains("PROFIT")) {
// newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
// }
2026-02-19 16:20:15 +09:00
KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue")
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.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}"
} else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
}
}
2026-02-19 15:47:31 +09:00
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.toDouble() * KisSession.config.getValues(configKey) + KisSession.config.getValues(
2026-02-19 16:20:15 +09:00
ConfigIndex.TAX_INDEX)} "
2026-02-19 15:47:31 +09:00
} else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
}
2026-02-19 16:20:15 +09:00
OutlinedTextField(
value = localText,
onValueChange = { localText = it }, // 화면에는 즉시 반영
label = { Text(labelText) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
// 2. 포커스를 잃었을 때 저장
if (!focusState.isFocused) {
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
2026-02-19 15:47:31 +09:00
saveAction()
}
2026-02-19 16:20:15 +09:00
),
singleLine = true
)
}
2026-02-19 15:47:31 +09:00
}
}
2026-01-22 16:21:18 +09:00
}
VerticalDivider()
2026-02-19 15:47:31 +09:00
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
2026-01-14 15:42:26 +09:00
AutoTradeSection(
2026-01-19 17:09:37 +09:00
isDomestic = isDomestic,
2026-01-14 15:42:26 +09:00
tradeService = tradeService,
onRefresh = { refreshTrigger++ },
2026-01-19 17:09:37 +09:00
refreshTrigger = refreshTrigger , // 트리거 전달
onItemCancel = { item ->
scope.launch {
tradeService.cancelOrder(item.orderNo,item.code).onSuccess {
refreshTrigger++
}
}
},
onItemSelect = { item ->
selectedStockCode = item.code
selectedStockName = item.name
isDomestic = item.isDomestic
})
2026-01-14 15:42:26 +09:00
}
VerticalDivider()
2026-01-13 16:04:25 +09:00
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
2026-02-19 15:47:31 +09:00
Column(modifier = Modifier.weight(0.12f).fillMaxHeight().padding(8.dp)) {
2026-01-13 16:04:25 +09:00
MarketSection(tradeService) { code, name, isDom ->
2026-01-19 17:09:37 +09:00
val info = StockBasicInfo(
code = code,
name = name,
isDomestic = isDom
)
selectedStockInfo = info
2026-01-10 18:16:50 +09:00
selectedStockCode = code
selectedStockName = name
2026-01-13 16:04:25 +09:00
isDomestic = isDom
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
2026-01-10 18:16:50 +09:00
}
}
}
2026-01-23 17:05:09 +09:00
2026-01-10 18:16:50 +09:00
}
2026-01-23 17:05:09 +09:00
2026-01-10 18:16:50 +09:00
@Composable
2026-01-13 16:04:25 +09:00
fun VerticalDivider() {
Box(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
2026-02-19 15:47:31 +09:00
}
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()
2026-01-10 18:16:50 +09:00
}