...
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// src/main/kotlin/ui/ActiveTradeRow.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -13,20 +14,21 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.ActiveTradeItem
|
||||
import model.ActiveTradeType
|
||||
|
||||
@Composable
|
||||
fun ActiveTradeRow(
|
||||
item: ActiveTradeItem,
|
||||
onCancelClick: (String) -> Unit = {}, // 미체결 취소용
|
||||
item: AutoTradeItem, // UI 모델 대신 통합 데이터 모델 사용
|
||||
onCancelClick: () -> Unit, // 미체결 취소 시 주문번호(orderNo) 전달
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isMonitoring = item.type == ActiveTradeType.MONITORING
|
||||
|
||||
// 상태에 따른 배경색 설정 (미체결은 연노랑으로 강조)
|
||||
val backgroundColor = if (isMonitoring) Color.White else Color(0xFFFFF9C4)
|
||||
val badgeColor = if (isMonitoring) Color(0xFF0E62CF) else Color(0xFFE03E2D)
|
||||
// 상태에 따른 UI 구성 요소 정의
|
||||
val (statusText, statusColor, backgroundColor) = when (item.status) {
|
||||
"PENDING_BUY" -> Triple("매수중", Color(0xFFFBC02D), Color(0xFFFFF9C4)) // 노랑
|
||||
"MONITORING" -> Triple("감시중", Color(0xFF0E62CF), Color.White) // 파랑
|
||||
"SELLING" -> Triple("매도중", Color(0xFFE03E2D), Color(0xFFFFF4F4)) // 빨강
|
||||
"COMPLETED" -> Triple("완료", Color.Gray, Color(0xFFF5F5F5)) // 회색
|
||||
else -> Triple("알 수 없음", Color.Black, Color.White)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -39,65 +41,67 @@ fun ActiveTradeRow(
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 좌측 정보 영역
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 상태 배지 (자동감시 / 미체결)
|
||||
// 상태 배지 표시
|
||||
Surface(
|
||||
color = badgeColor,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
color = statusColor,
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isMonitoring) "자동감시" else "미체결",
|
||||
text = statusText,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
// 상세 가격 정보 (상태에 따라 비율 또는 목표가 표시)
|
||||
val detailText = when (item.status) {
|
||||
"PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%"
|
||||
"MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}"
|
||||
else -> "주문번호: ${item.orderNo}"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${item.code} | ${if (isMonitoring) "목표가" else "주문가"}: ${String.format("%,.0f", item.price)}",
|
||||
text = "${item.code} | $detailText",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 우측 액션 영역
|
||||
// 우측 액션 및 수량 영역
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
if (!isMonitoring) {
|
||||
// 미체결인 경우 취소 버튼 표시
|
||||
if (item.status == "PENDING_BUY" || item.status == "SELLING") {
|
||||
// 진행 중인 주문인 경우 취소 버튼 노출
|
||||
Button(
|
||||
onClick = { onCancelClick(item.id) },
|
||||
onClick = { onCancelClick() },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
modifier = Modifier.height(28.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
|
||||
) {
|
||||
Text("취소", fontSize = 11.sp)
|
||||
}
|
||||
Text(
|
||||
text = "${item.quantity}주",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFE03E2D)
|
||||
)
|
||||
} else {
|
||||
// 자동감시 중인 경우 상태 텍스트 표시
|
||||
Text(
|
||||
text = "감시중",
|
||||
fontSize = 12.sp,
|
||||
color = badgeColor,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${item.quantity}주",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,51 +15,35 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import model.ActiveTradeItem
|
||||
import model.ActiveTradeType
|
||||
import model.toAutoTradeItem
|
||||
import network.KisTradeService
|
||||
|
||||
// src/main/kotlin/ui/AutoTradeSection.kt
|
||||
|
||||
@Composable
|
||||
fun AutoTradeSection(
|
||||
isDomestic: Boolean,
|
||||
tradeService: KisTradeService,
|
||||
refreshTrigger: Int, // 갱신 트리거 추가
|
||||
onRefresh: () -> Unit,
|
||||
onItemSelect: (ActiveTradeItem) -> Unit
|
||||
onItemSelect: (AutoTradeItem) -> Unit,
|
||||
onItemCancel: (AutoTradeItem) -> Unit
|
||||
) {
|
||||
// 통합 리스트 상태 (ActiveTradeItem은 이전에 정의한 통합 모델)
|
||||
var combinedList by remember { mutableStateOf(emptyList<ActiveTradeItem>()) }
|
||||
|
||||
var tradeList by remember { mutableStateOf(emptyList<AutoTradeItem>()) }
|
||||
// refreshTrigger가 바뀔 때마다 실행됨
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
// 1. DB에서 감시 중인 종목 로드
|
||||
val monitoringItems = DatabaseFactory.getActiveAutoTrades().map {
|
||||
ActiveTradeItem(
|
||||
id = it.code,
|
||||
code = it.code,
|
||||
name = it.name,
|
||||
type = ActiveTradeType.MONITORING,
|
||||
price = it.targetPrice,
|
||||
quantity = "-",
|
||||
isDomestic = it.isDomestic
|
||||
)
|
||||
}
|
||||
val serverUnfilled = tradeService.fetchUnfilledOrders().getOrNull()?.map { it.toAutoTradeItem(isDomestic) } ?: emptyList()
|
||||
|
||||
// 2. KIS API에서 미체결 주문 로드
|
||||
val unfilledItems = tradeService.fetchUnfilledOrders().getOrDefault(emptyList()).map {
|
||||
ActiveTradeItem(
|
||||
id = it.ord_no,
|
||||
code = it.pdno,
|
||||
name = it.prdt_name,
|
||||
type = ActiveTradeType.UNFILLED,
|
||||
price = it.ord_unpr.toDouble(),
|
||||
quantity = it.rmnd_qty,
|
||||
isDomestic = true
|
||||
)
|
||||
}
|
||||
// 2. DB에서 로컬 감시 데이터 가져오기
|
||||
val localTrades = DatabaseFactory.getActiveAutoTrades()
|
||||
|
||||
combinedList = monitoringItems + unfilledItems
|
||||
// 3. 동기화 로직: 서버에 없는 주문번호를 가진 로컬 데이터는 EXPIRED로 표시
|
||||
tradeList = localTrades.map { local ->
|
||||
if (local.status != "COMPLETED" && serverUnfilled.none { it.orderNo == local.orderNo }) {
|
||||
local.copy(status = "EXPIRED")
|
||||
} else local
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||
@@ -84,15 +68,11 @@ fun AutoTradeSection(
|
||||
}
|
||||
}
|
||||
LazyColumn {
|
||||
items(combinedList) { item ->
|
||||
items(tradeList) { item ->
|
||||
ActiveTradeRow(
|
||||
item = item,
|
||||
onCancelClick = { orderNo ->
|
||||
// tradeService.cancelOrder(orderNo, item.code) 호출 로직
|
||||
},
|
||||
onClick = {
|
||||
onItemSelect(item) // 상세 화면 전환용 콜백
|
||||
}
|
||||
onCancelClick = { onItemCancel(item) }, // 이미 스코프에 있는 item을 그대로 사용
|
||||
onClick = { onItemSelect(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,116 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.BalanceSummary
|
||||
import model.CandleData
|
||||
import model.RankingStock
|
||||
import model.StockHolding
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
|
||||
@Composable
|
||||
fun AutoTradeSettingCard(stockCode: String, currentPrice: String) {
|
||||
var profitRate by remember { mutableStateOf("5.0") }
|
||||
var stopLossRate by remember { mutableStateOf("-3.0") }
|
||||
var isEnabled by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
elevation = 4.dp,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
backgroundColor = Color(0xFFF8F9FA) // detail.html의 order-box 배경색 참고
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = profitRate,
|
||||
onValueChange = { profitRate = it },
|
||||
label = { Text("익절 %") },
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = stopLossRate,
|
||||
onValueChange = { stopLossRate = it },
|
||||
label = { Text("손절 %") },
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = { isEnabled = !isEnabled },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
)
|
||||
) {
|
||||
Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//package ui
|
||||
//
|
||||
//import AutoTradeItem
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.runtime.*
|
||||
//import io.ktor.client.engine.cio.CIO
|
||||
//// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
//import androidx.compose.runtime.getValue
|
||||
//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.FontWeight
|
||||
//import androidx.compose.ui.text.style.TextAlign
|
||||
//import androidx.compose.ui.text.style.TextOverflow
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import kotlinx.coroutines.delay
|
||||
//import kotlinx.coroutines.launch
|
||||
//import model.AppConfig
|
||||
//import model.BalanceSummary
|
||||
//import model.CandleData
|
||||
//import model.RankingStock
|
||||
//import model.StockHolding
|
||||
//import network.KisTradeService
|
||||
//import network.KisWebSocketManager
|
||||
//import kotlin.collections.isNotEmpty
|
||||
//
|
||||
//
|
||||
//@Composable
|
||||
//fun AutoTradeSettingCard(
|
||||
// stockCode: String,
|
||||
// stockName: String, // 종목명 추가
|
||||
// currentPrice: String,
|
||||
// isDomestic: Boolean = true
|
||||
//) {
|
||||
// var profitRate by remember { mutableStateOf("5.0") }
|
||||
// var stopLossRate by remember { mutableStateOf("-3.0") }
|
||||
//
|
||||
// // DB에서 현재 감시 중인지 확인
|
||||
// var isEnabled by remember(stockCode) {
|
||||
// mutableStateOf(DatabaseFactory.findConfigByCode(stockCode) != null)
|
||||
// }
|
||||
//
|
||||
// Card(
|
||||
// elevation = 4.dp,
|
||||
// shape = RoundedCornerShape(8.dp),
|
||||
// backgroundColor = Color(0xFFF8F9FA)
|
||||
// ) {
|
||||
// Column(modifier = Modifier.padding(12.dp)) {
|
||||
// Text("자동 매도 설정 (AI 감시)", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.subtitle2)
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
//
|
||||
// Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// OutlinedTextField(
|
||||
// value = profitRate,
|
||||
// onValueChange = { profitRate = it },
|
||||
// label = { Text("익절 %") },
|
||||
// modifier = Modifier.weight(1f).padding(end = 4.dp),
|
||||
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
|
||||
// )
|
||||
// OutlinedTextField(
|
||||
// value = stopLossRate,
|
||||
// onValueChange = { stopLossRate = it },
|
||||
// label = { Text("손절 %") },
|
||||
// modifier = Modifier.weight(1f),
|
||||
// textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
//
|
||||
// Button(
|
||||
// onClick = {
|
||||
// if (!isEnabled) {
|
||||
// // 자동 매매 시작: DB 저장
|
||||
// val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
// val target = curPriceNum * (1 + profitRate.toDouble() / 100.0)
|
||||
// val stopLoss = curPriceNum * (1 + stopLossRate.toDouble() / 100.0)
|
||||
//
|
||||
// DatabaseFactory.saveAutoTrade(
|
||||
// AutoTradeItem(
|
||||
// code = stockCode,
|
||||
// name = stockName,
|
||||
// targetPrice = target,
|
||||
// stopLossPrice = stopLoss,
|
||||
// status = "MONITORING",
|
||||
// isDomestic = isDomestic
|
||||
// )
|
||||
// )
|
||||
// // [중요] 웹소켓 실시간 감시 등록 로직이 이곳에 호출되어야 함
|
||||
// // KisWebSocketManager.subscribe(stockCode)
|
||||
// isEnabled = true
|
||||
// } else {
|
||||
// // 자동 매매 중단: DB 삭제
|
||||
// DatabaseFactory.deleteAutoTrade(stockCode)
|
||||
// // KisWebSocketManager.unsubscribe(stockCode)
|
||||
// isEnabled = false
|
||||
// }
|
||||
// },
|
||||
// modifier = Modifier.fillMaxWidth(),
|
||||
// colors = ButtonDefaults.buttonColors(
|
||||
// backgroundColor = if (isEnabled) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
// )
|
||||
// ) {
|
||||
// Text(if (isEnabled) "자동 매매 중단" else "자동 매매 시작", color = Color.White)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -6,8 +6,11 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -17,13 +20,15 @@ import network.KisTradeService
|
||||
@Composable
|
||||
fun BalanceSection(
|
||||
tradeService: KisTradeService,
|
||||
onStockSelect: (code: String, name: String, isDomestic: Boolean) -> Unit
|
||||
refreshTrigger: Int, // 갱신 트리거 추가
|
||||
onRefresh: () -> Unit,
|
||||
onStockSelect: (code: String, name: String, isDomestic: Boolean,quantity: String) -> Unit
|
||||
) {
|
||||
var balanceData by remember { mutableStateOf<UnifiedBalance?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
// 화면 진입 시 및 갱신 시 데이터 로드
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
isLoading = true
|
||||
tradeService.fetchIntegratedBalance().onSuccess {
|
||||
balanceData = it
|
||||
@@ -34,13 +39,30 @@ fun BalanceSection(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = "나의 자산",
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "나의 자산",
|
||||
style = MaterialTheme.typography.h6,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
// 강제 갱신 버튼
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Refresh,
|
||||
contentDescription = "새로고침",
|
||||
tint = Color(0xFF0E62CF),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
// 1. 자산 요약 카드
|
||||
BalanceSummaryCard(balanceData)
|
||||
|
||||
@@ -62,7 +84,7 @@ fun BalanceSection(
|
||||
LazyColumn(modifier = Modifier.weight(1f, true)) {
|
||||
items(balanceData?.holdings ?: emptyList()) { holding ->
|
||||
UnifiedStockItemRow(holding) {
|
||||
onStockSelect(holding.code, holding.name, holding.isDomestic)
|
||||
onStockSelect(holding.code, holding.name, holding.isDomestic, holding.quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +100,7 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
backgroundColor = androidx.compose.ui.graphics.Color.White
|
||||
) {
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
Text(
|
||||
@@ -103,6 +126,9 @@ fun BalanceSummaryCard(summary: UnifiedBalance?) {
|
||||
|
||||
@Composable
|
||||
fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit) {
|
||||
val avgPrice = holding.avgPrice.toDoubleOrNull() ?: 0.0
|
||||
val breakEvenPrice = if (avgPrice > 0) avgPrice / 0.9978 else 0.0
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
|
||||
elevation = 1.dp
|
||||
@@ -126,19 +152,27 @@ fun UnifiedStockItemRow(holding: model.UnifiedStockHolding, onClick: () -> Unit)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(holding.name, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
Text(
|
||||
"매수: ${String.format("%,.0f", avgPrice)}원 ${holding.quantity}",
|
||||
fontSize = 11.sp, color = Color.Gray
|
||||
)
|
||||
}
|
||||
Text(holding.code, style = MaterialTheme.typography.caption, color = androidx.compose.ui.graphics.Color.Gray)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
|
||||
Text("${holding.currentPrice} 원")
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("${holding.currentPrice} 원", fontWeight = FontWeight.Bold)
|
||||
// 손익분기점 표시 추가
|
||||
Text(
|
||||
"손익분기: ${String.format("%,.0f", breakEvenPrice)}원",
|
||||
fontSize = 10.sp, color = Color(0xFF666666)
|
||||
)
|
||||
val rate = holding.profitRate.toDoubleOrNull() ?: 0.0
|
||||
Text(
|
||||
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${holding.profitRate}%",
|
||||
color = if (rate > 0) androidx.compose.ui.graphics.Color.Red
|
||||
else if (rate < 0) androidx.compose.ui.graphics.Color.Blue
|
||||
else androidx.compose.ui.graphics.Color.DarkGray,
|
||||
fontSize = 12.sp
|
||||
text = "${if (rate > 0) "+" else ""}${holding.profitRate}%",
|
||||
color = if (rate > 0) Color.Red else if (rate < 0) Color.Blue else Color.DarkGray,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,87 +12,80 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.KisSession
|
||||
import model.StockBasicInfo
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import util.MarketUtil
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen() {
|
||||
val tradeService = remember { KisTradeService() }
|
||||
val wsManager = remember { KisWebSocketManager() }
|
||||
val config = KisSession.config
|
||||
val scope = rememberCoroutineScope()
|
||||
// 데이터 갱신을 위한 트리거 상태
|
||||
var refreshTrigger by remember { mutableStateOf(0) }
|
||||
// 전역 상태: 현재 선택된 종목 정보
|
||||
var selectedStockCode by remember { mutableStateOf("") }
|
||||
var selectedStockName by remember { mutableStateOf("") }
|
||||
var isDomestic by remember { mutableStateOf(true) }
|
||||
var selectedStockQuantity by remember { mutableStateOf("0") }
|
||||
|
||||
var selectedItem by remember { mutableStateOf<AutoTradeItem?>(null) } // 감시/미체결 아이템 선택 시
|
||||
var selectedStockInfo by remember { mutableStateOf<StockBasicInfo?>(null) } // 단순 종목 선택 시
|
||||
|
||||
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// 1. 웹소켓 연결
|
||||
wsManager.connect()
|
||||
|
||||
// 2. 체결 통보 콜백 설정 (매수 성공 시 감시 시작)
|
||||
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
|
||||
if (isBuy) {
|
||||
// [매수 체결 시] DB에 감시 데이터 저장
|
||||
// 주의: targetPrice와 stopLossPrice는 이전에 설정된 값을 가져오거나
|
||||
// 임시 상태값에서 가져와야 함 (여기선 예시로 현재가의 +5%, -3% 설정)
|
||||
val execPrice = price.toDoubleOrNull() ?: 0.0
|
||||
DatabaseFactory.saveAutoTrade(
|
||||
AutoTradeItem(
|
||||
code = code,
|
||||
name = "", // 필요 시 종목명 매핑
|
||||
targetPrice = execPrice * 1.05,
|
||||
stopLossPrice = execPrice * 0.97,
|
||||
status = "MONITORING",
|
||||
isDomestic = true
|
||||
)
|
||||
)
|
||||
println("📝 매수 체결로 인한 자동 감시 등록: $code")
|
||||
} else {
|
||||
// [매도 체결 시] 감시 종료 및 DB 삭제
|
||||
DatabaseFactory.deleteAutoTrade(code)
|
||||
println("✅ 매도 체결로 인한 감시 종료: $code")
|
||||
}
|
||||
// 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)
|
||||
|
||||
refreshTrigger++
|
||||
}
|
||||
|
||||
// 3. 목표가 도달 콜백 설정 (자동 매도 실행)
|
||||
wsManager.onTargetReached = { code, price, isProfit ->
|
||||
// 3. 실시간 체결 통보 핸들러 (주문번호 중심)
|
||||
wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy ->
|
||||
scope.launch {
|
||||
println("🚀 목표가 도달! 자동 매도 주문 실행: $code (이유: ${if(isProfit) "익절" else "손절"})")
|
||||
|
||||
// 실제 매도 주문 API 호출
|
||||
tradeService.postOrder(
|
||||
stockCode = code,
|
||||
qty = "1", // 실제론 보유 수량을 가져와야 함
|
||||
price = "0", // 시장가 매도
|
||||
isBuy = false
|
||||
).onSuccess {
|
||||
// 매도 주문 성공 시 로그 기록
|
||||
DatabaseFactory.saveTradeLog(
|
||||
code, "", "매도", price, 1,
|
||||
if(isProfit) "AI 익절 조건 달성" else "AI 손절 조건 달성"
|
||||
)
|
||||
val dbItem = DatabaseFactory.findByOrderNo(orderNo)
|
||||
if (dbItem != null) {
|
||||
when (dbItem.status) {
|
||||
TradeStatus.PENDING_BUY -> {
|
||||
// 매수 주문 체결 -> 감시(MONITORING)로 전환 및 익절 주문 발주
|
||||
// 여기서 익절 주문 후 받은 신규 주문번호를 DB에 갱신
|
||||
// updateStatusAndOrderNo(dbItem.id!!, TradeStatus.MONITORING, newSellOrderNo)
|
||||
}
|
||||
TradeStatus.SELLING -> {
|
||||
// 매도(손절/익절) 주문 체결 -> COMPLETED
|
||||
DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED)
|
||||
}
|
||||
}
|
||||
refreshTrigger++
|
||||
}
|
||||
}
|
||||
refreshTrigger++
|
||||
}
|
||||
|
||||
if (config.htsId.isNotEmpty()) {
|
||||
wsManager.subscribeExecution(config.htsId)
|
||||
println("📡 HTS ID(${config.htsId})로 체결 통보 구독을 시작합니다.")
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
|
||||
// [좌측 25%] 내 자산 및 통합 잔고
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
BalanceSection(tradeService) { code, name, isDom ->
|
||||
BalanceSection(tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger) { code, name, isDom,qty ->
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
selectedStockQuantity = qty
|
||||
println("selectedStockCode $selectedStockCode selectedStockName $selectedStockName isDomestic $isDomestic")
|
||||
}
|
||||
}
|
||||
@@ -105,6 +98,7 @@ fun DashboardScreen() {
|
||||
StockDetailSection(
|
||||
stockCode = selectedStockCode,
|
||||
stockName = selectedStockName,
|
||||
holdingQuantity = selectedStockQuantity,
|
||||
isDomestic = isDomestic,
|
||||
tradeService = tradeService,
|
||||
wsManager = wsManager
|
||||
@@ -119,19 +113,33 @@ fun DashboardScreen() {
|
||||
VerticalDivider()
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
AutoTradeSection(
|
||||
isDomestic = isDomestic,
|
||||
tradeService = tradeService,
|
||||
onRefresh = { refreshTrigger++ },
|
||||
refreshTrigger = refreshTrigger // 트리거 전달
|
||||
) { item ->
|
||||
selectedStockCode = item.code
|
||||
selectedStockName = item.name
|
||||
isDomestic = item.isDomestic
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
VerticalDivider()
|
||||
// [우측 30%] 시장 추천 TOP 20 (실전 데이터)
|
||||
Column(modifier = Modifier.weight(0.18f).fillMaxHeight().padding(8.dp)) {
|
||||
MarketSection(tradeService) { code, name, isDom ->
|
||||
val info = StockBasicInfo(
|
||||
code = code,
|
||||
name = name,
|
||||
isDomestic = isDom
|
||||
)
|
||||
selectedStockInfo = info
|
||||
selectedStockCode = code
|
||||
selectedStockName = name
|
||||
isDomestic = isDom
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/main/kotlin/ui/IntegratedOrderSection.kt
|
||||
package ui
|
||||
|
||||
import AutoTradeItem
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -15,29 +16,56 @@ import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import network.KisTradeService
|
||||
|
||||
/**
|
||||
* 통합 주문 및 자동매매 설정 섹션
|
||||
* * [수정 사항]
|
||||
* 1. stockCode 기반이 아닌 DB 객체(monitoringItem) 기반의 상태 관리로 데이터 꼬임 방지
|
||||
* 2. 수량 입력 시 콤마 제거 및 Int 변환 예외 처리 적용
|
||||
* 3. 매수 성공 시 반환받은 실제 주문번호(ODNO)를 DB에 저장하여 주문번호 중심 관리 구현
|
||||
*/
|
||||
@Composable
|
||||
fun IntegratedOrderSection(
|
||||
stockCode: String,
|
||||
stockName: String,
|
||||
isDomestic: Boolean,
|
||||
currentPrice: String,
|
||||
holdingQuantity: String,
|
||||
tradeService: KisTradeService,
|
||||
onOrderResult: (String, Boolean) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var orderQty by remember { mutableStateOf("1") }
|
||||
|
||||
// 1. 상태 관리: 현재 종목의 감시 설정 여부를 DB에서 로드하여 객체 단위로 관리
|
||||
var monitoringItem by remember(stockCode) {
|
||||
mutableStateOf(DatabaseFactory.findConfigByCode(stockCode))
|
||||
}
|
||||
|
||||
val isAutoSellEnabled = monitoringItem != null
|
||||
|
||||
// UI 입력 상태
|
||||
var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가
|
||||
var orderQty by remember(holdingQuantity) {
|
||||
// 보유수량이 있으면 해당 수량, 없으면 기본 1주 (콤마 제거 처리)
|
||||
val cleanQty = holdingQuantity.replace(",", "")
|
||||
mutableStateOf(if(cleanQty == "0" || cleanQty.isEmpty()) "1" else cleanQty)
|
||||
}
|
||||
|
||||
// 자동 매도 설정
|
||||
var isAutoSellEnabled by remember { mutableStateOf(false) }
|
||||
var profitRate by remember { mutableStateOf("5.0") }
|
||||
var stopLossRate by remember { mutableStateOf("-3.0") }
|
||||
var profitRate by remember(monitoringItem) {
|
||||
mutableStateOf(monitoringItem?.profitRate?.toString() ?: "3.0")
|
||||
}
|
||||
var stopLossRate by remember(monitoringItem) {
|
||||
mutableStateOf(monitoringItem?.stopLossRate?.toString() ?: "-3.0")
|
||||
}
|
||||
|
||||
val basePrice = (if (orderPrice.isEmpty()) currentPrice.replace(",", "") else orderPrice).toDoubleOrNull() ?: 0.0
|
||||
val qty = orderQty.toDoubleOrNull() ?: 0.0
|
||||
// 계산용 변수
|
||||
val curPriceNum = currentPrice.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
val basePrice = (if (orderPrice.isEmpty()) curPriceNum else orderPrice.toDoubleOrNull() ?: 0.0)
|
||||
val inputQty = orderQty.replace(",", "").toIntOrNull() ?: 0
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Text("주문 및 자동 매도 설정", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 1. 가격 및 수량 입력
|
||||
// 가격 및 수량 입력 필드
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = orderQty,
|
||||
@@ -54,71 +82,121 @@ fun IntegratedOrderSection(
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 수익률 시뮬레이션 표 (신규 추가)
|
||||
if (basePrice > 0 && qty > 0) {
|
||||
Text("익절/손절 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 11.sp, color = Color.Gray, modifier = Modifier.padding(bottom = 4.dp))
|
||||
Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%"), true)
|
||||
SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() }, false)
|
||||
SimulationColumn("예상수령액", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate ->
|
||||
val sellPrice = basePrice * rate
|
||||
val totalAmount = sellPrice * qty
|
||||
val netAmount = totalAmount * (1 - 0.0022) // 수수료+세금 약 0.22% 차감
|
||||
String.format("%,d", netAmount.toLong())
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 수익률 시뮬레이션 표
|
||||
if (basePrice > 0 && inputQty > 0) {
|
||||
SimulationCard(basePrice, inputQty.toDouble())
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 3. 자동 매도 옵션
|
||||
// 실시간 AI 매도 감시 설정 카드
|
||||
Card(backgroundColor = Color(0xFFF8F9FA), elevation = 0.dp) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isAutoSellEnabled, onCheckedChange = { isAutoSellEnabled = it })
|
||||
Text("매수 체결 시 자동 매도 감시 시작", fontSize = 12.sp)
|
||||
Checkbox(
|
||||
checked = isAutoSellEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (!checked) {
|
||||
// [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지)
|
||||
monitoringItem?.id?.let { dbId ->
|
||||
DatabaseFactory.deleteAutoTrade(dbId)
|
||||
monitoringItem = null
|
||||
println("🗑️ 감시 해제: $stockName (ID: $dbId)")
|
||||
}
|
||||
} else {
|
||||
// [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작
|
||||
if (curPriceNum > 0) {
|
||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||
val target = curPriceNum * (1 + pRate / 100.0)
|
||||
val stop = curPriceNum * (1 + sRate / 100.0)
|
||||
|
||||
val newItem = AutoTradeItem(
|
||||
orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}",
|
||||
code = stockCode,
|
||||
name = stockName,
|
||||
quantity = inputQty,
|
||||
profitRate = pRate,
|
||||
stopLossRate = sRate,
|
||||
targetPrice = target,
|
||||
stopLossPrice = stop,
|
||||
status = "MONITORING",
|
||||
isDomestic = isDomestic
|
||||
)
|
||||
DatabaseFactory.saveAutoTrade(newItem)
|
||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Text("실시간 AI 매도 감시 활성화", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
if (isAutoSellEnabled) {
|
||||
Row {
|
||||
OutlinedTextField(
|
||||
value = profitRate, onValueChange = { profitRate = it },
|
||||
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = stopLossRate, onValueChange = { stopLossRate = it },
|
||||
label = { Text("손절 %") }, modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row {
|
||||
OutlinedTextField(
|
||||
value = profitRate, onValueChange = { profitRate = it },
|
||||
label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp),
|
||||
enabled = !isAutoSellEnabled
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = stopLossRate, onValueChange = { stopLossRate = it },
|
||||
label = { Text("손절 %") }, modifier = Modifier.weight(1f),
|
||||
enabled = !isAutoSellEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 4. 매수/매도 버튼
|
||||
// 매수 / 매도 실행 버튼
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
// 매수 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
|
||||
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true)
|
||||
.onSuccess {
|
||||
onOrderResult(it, true)
|
||||
if (isAutoSellEnabled) { /* 자동매도 등록 로직 호출 */ }
|
||||
.onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호
|
||||
onOrderResult("주문 성공: $realOrderNo", true)
|
||||
if (isAutoSellEnabled) {
|
||||
val pRate = profitRate.toDoubleOrNull() ?: 0.0
|
||||
val sRate = stopLossRate.toDoubleOrNull() ?: 0.0
|
||||
DatabaseFactory.saveAutoTrade(AutoTradeItem(
|
||||
orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙)
|
||||
code = stockCode,
|
||||
name = stockName,
|
||||
quantity = inputQty,
|
||||
profitRate = pRate,
|
||||
stopLossRate = sRate,
|
||||
targetPrice = basePrice * (1 + pRate / 100.0),
|
||||
stopLossPrice = basePrice * (1 + sRate / 100.0),
|
||||
status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태
|
||||
isDomestic = isDomestic
|
||||
))
|
||||
monitoringItem = DatabaseFactory.findConfigByCode(stockCode)
|
||||
}
|
||||
}
|
||||
.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
.onFailure { onOrderResult(it.message ?: "매수 실패", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
|
||||
) { Text("매수", color = Color.White) }
|
||||
|
||||
// 매도 버튼
|
||||
Button(
|
||||
onClick = { /* 매도 로직동일 */ },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val finalPrice = if (orderPrice.isBlank()) "0" else orderPrice
|
||||
tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = false)
|
||||
.onSuccess { realOrderNo ->
|
||||
onOrderResult("매도 주문 성공: $realOrderNo", true)
|
||||
// 매도 시 기존 감시 설정이 있다면 상태 변경 등 추가 로직 가능
|
||||
}
|
||||
.onFailure { onOrderResult(it.message ?: "매도 실패", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
|
||||
) { Text("매도", color = Color.White) }
|
||||
@@ -127,16 +205,33 @@ fun IntegratedOrderSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimulationColumn(title: String, items: List<String>, isHeader: Boolean) {
|
||||
fun SimulationCard(basePrice: Double, qty: Double) {
|
||||
Card(backgroundColor = Color(0xFFF1F3F5), shape = RoundedCornerShape(4.dp), elevation = 0.dp) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text("수익률 시뮬레이션 (수수료/세금 약 0.22% 반영)", fontSize = 10.sp, color = Color.Gray)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
SimulationColumn("수익률", listOf("+5%", "+3%", "+1%", "-1%", "-3%", "-5%"))
|
||||
SimulationColumn("목표가", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { (basePrice * it).toLong().toString() })
|
||||
SimulationColumn("예상수령", listOf(1.05, 1.03, 1.01, 0.99, 0.97, 0.95).map { rate ->
|
||||
val netAmount = (basePrice * rate * qty) * (1 - 0.0022)
|
||||
String.format("%,d", netAmount.toLong())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimulationColumn(title: String, items: List<String>) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(title, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.DarkGray)
|
||||
items.forEach { text ->
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 11.sp,
|
||||
color = if (text.contains("+")) Color(0xFFE03E2D) else if (text.contains("-")) Color(0xFF0E62CF) else Color.Black,
|
||||
modifier = Modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
val color = when {
|
||||
text.contains("+") -> Color(0xFFE03E2D)
|
||||
text.contains("-") -> Color(0xFF0E62CF)
|
||||
else -> Color.Black
|
||||
}
|
||||
Text(text = text, fontSize = 11.sp, color = color, modifier = Modifier.padding(vertical = 1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package ui
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.BalanceSummary
|
||||
import model.RankingStock
|
||||
import model.StockHolding
|
||||
import network.KisTradeService
|
||||
|
||||
@Composable
|
||||
fun MyStockList(
|
||||
holdings: List<StockHolding>,
|
||||
onSelect: (code: String, name: String) -> Unit
|
||||
) {
|
||||
if (holdings.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("보유 종목이 없습니다.", color = Color.Gray, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(holdings) { stock ->
|
||||
MyStockItemRow(stock) {
|
||||
onSelect(stock.pdno, stock.prdt_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MyStockItemRow(
|
||||
stock: StockHolding,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() },
|
||||
elevation = 1.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
backgroundColor = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 1. 종목명 및 코드
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stock.prdt_name,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = stock.pdno,
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
// 2. 수익률 배지 (웹 소스 컬러 적용)
|
||||
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
|
||||
val color = when {
|
||||
rate > 0 -> Color(0xFFE03E2D) // 매수색
|
||||
rate < 0 -> Color(0xFF0E62CF) // 매도색
|
||||
else -> Color.DarkGray
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Surface(
|
||||
color = color.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${if (rate > 0) "+" else ""}${stock.evlu_pfls_rt}%",
|
||||
color = color,
|
||||
style = MaterialTheme.typography.caption,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${stock.prpr}원",
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = Color.DarkGray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package ui
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
import androidx.compose.runtime.getValue
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import model.BalanceSummary
|
||||
import model.CandleData
|
||||
import model.RankingStock
|
||||
import model.StockHolding
|
||||
import network.KisTradeService
|
||||
import network.KisWebSocketManager
|
||||
import kotlin.collections.isNotEmpty
|
||||
|
||||
@Composable
|
||||
fun OrderSection(
|
||||
stockCode: String,
|
||||
currentPrice: String,
|
||||
onOrderResult: (String, Boolean) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val tradeService = remember { KisTradeService() } // 전역 세션 참조 버전
|
||||
var orderQty by remember { mutableStateOf("1") }
|
||||
var orderPrice by remember { mutableStateOf("0") } // "0"은 시장가
|
||||
|
||||
Column(modifier = Modifier.width(200.dp).background(Color(0xFFF8F9FA)).padding(8.dp)) {
|
||||
Text("주문 설정", fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
|
||||
OutlinedTextField(
|
||||
value = orderQty,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
|
||||
label = { Text("수량") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = if(orderPrice == "0") "시장가" else orderPrice,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
|
||||
label = { Text("가격") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
// KisSession을 사용하는 postOrder 호출
|
||||
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = true)
|
||||
.onSuccess { onOrderResult(it, true) }
|
||||
.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D))
|
||||
) {
|
||||
Text("매수", color = Color.White)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
tradeService.postOrder(stockCode, orderQty, orderPrice, isBuy = false)
|
||||
.onSuccess { onOrderResult(it, true) }
|
||||
.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF))
|
||||
) {
|
||||
Text("매도", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
//package ui
|
||||
//
|
||||
//
|
||||
//import androidx.compose.foundation.BorderStroke
|
||||
//import androidx.compose.foundation.background
|
||||
//import androidx.compose.foundation.clickable
|
||||
//import androidx.compose.foundation.layout.*
|
||||
//import androidx.compose.foundation.lazy.LazyColumn
|
||||
//import androidx.compose.foundation.lazy.items // 반드시 수동 import 확인
|
||||
//import androidx.compose.foundation.lazy.itemsIndexed
|
||||
//import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
//import androidx.compose.material.*
|
||||
//import androidx.compose.material.TabRowDefaults.tabIndicatorOffset
|
||||
//import androidx.compose.runtime.*
|
||||
//import io.ktor.client.engine.cio.CIO
|
||||
//// 아래 두 import가 'delegate' 에러를 해결합니다.
|
||||
//import androidx.compose.runtime.getValue
|
||||
//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.FontWeight
|
||||
//import androidx.compose.ui.text.style.TextOverflow
|
||||
//import androidx.compose.ui.unit.dp
|
||||
//import androidx.compose.ui.unit.sp
|
||||
//import kotlinx.coroutines.launch
|
||||
//import model.AppConfig
|
||||
//import model.BalanceSummary
|
||||
//import model.RankingStock
|
||||
//import model.RankingType
|
||||
//import model.StockHolding
|
||||
//import network.KisTradeService
|
||||
//import util.MarketUtil
|
||||
//
|
||||
//@Composable
|
||||
//fun RecommendationTabs(
|
||||
// config: AppConfig,
|
||||
// token: String,
|
||||
// onSelect: (String, String) -> Unit
|
||||
//) {
|
||||
// var isDomestic by remember { mutableStateOf(true) }
|
||||
// var selectedType by remember { mutableStateOf(RankingType.RISE) }
|
||||
// var rankingList by remember { mutableStateOf<List<RankingStock>>(emptyList()) }
|
||||
//
|
||||
// val tradeService = remember { KisTradeService(config.isSimulation) }
|
||||
// val isKoreaOpen = MarketUtil.isKoreanMarketOpen()
|
||||
// var errorMessage by remember { mutableStateOf<String?>(null) } // 에러 메시지 상태 추가
|
||||
//
|
||||
// // 데이터 로드 로직
|
||||
// LaunchedEffect(isDomestic, selectedType, isKoreaOpen) {
|
||||
// errorMessage = null // 로딩 시작 시 에러 초기화
|
||||
// if (isDomestic) {
|
||||
// if (isKoreaOpen) {
|
||||
// tradeService.fetchMarketRanking(token, config, selectedType, true)
|
||||
// .onSuccess { rankingList = it.take(20) }
|
||||
// .onFailure { errorMessage = "실시간 데이터를 가져오지 못했습니다." }
|
||||
// } else {
|
||||
// tradeService.fetchDomesticPreviousDayRanking(token, config)
|
||||
// .onSuccess { rankingList = it }
|
||||
// .onFailure { errorMessage = "장외 데이터를 가져오지 못했습니다. (점검 중일 수 있음)" }
|
||||
// }
|
||||
// } else {
|
||||
// tradeService.fetchOverseasRanking(token, config)
|
||||
// .onSuccess { rankingList = it }
|
||||
// .onFailure { errorMessage = "해외 주식 데이터를 불러올 수 없습니다." }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Column(modifier = Modifier.fillMaxSize()) {
|
||||
// // [1] 국내/해외 전환 버튼 (항상 노출)
|
||||
// Row(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
// MarketToggleButton("국내 주식", isDomestic, Color(0xFFE03E2D)) { isDomestic = true }
|
||||
// Spacer(Modifier.width(8.dp))
|
||||
// MarketToggleButton("미국 주식", !isDomestic, Color(0xFF0E62CF)) { isDomestic = false }
|
||||
// }
|
||||
//
|
||||
// // [2] 랭킹 타입 탭 (상승/하락/거래량 등 - 항상 노출)
|
||||
// ScrollableTabRow(
|
||||
// selectedTabIndex = selectedType.ordinal,
|
||||
// edgePadding = 8.dp,
|
||||
// backgroundColor = Color.White,
|
||||
// indicator = { tabPositions ->
|
||||
// TabRowDefaults.Indicator(
|
||||
// modifier = Modifier.tabIndicatorOffset(tabPositions[selectedType.ordinal]),
|
||||
// color = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
// )
|
||||
// }
|
||||
// ) {
|
||||
// RankingType.values().forEach { type ->
|
||||
// Tab(
|
||||
// selected = selectedType == type,
|
||||
// onClick = { selectedType = type },
|
||||
// text = { Text(type.title, fontSize = 12.sp) }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // [3] 장외 시간 안내 바
|
||||
// if (isDomestic && !isKoreaOpen) {
|
||||
// Surface(color = Color(0xFFFFF9C4), modifier = Modifier.fillMaxWidth()) {
|
||||
// Text(
|
||||
// "현재 장외 시간입니다. 전일 종가 기준 TOP 20입니다.",
|
||||
// fontSize = 11.sp, modifier = Modifier.padding(8.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // [4] 추천 리스트 영역
|
||||
// Box(modifier = Modifier.weight(1f)) {
|
||||
// if (errorMessage != null) {
|
||||
// // 에러 발생 시 안내
|
||||
// Column(Modifier.fillMaxSize(), Arrangement.Center, Alignment.CenterHorizontally) {
|
||||
// Text(errorMessage!!, color = Color.Gray)
|
||||
// Button(onClick = { /* 다시 시도 로직 */ }) { Text("다시 시도") }
|
||||
// }
|
||||
// } else if (rankingList.isEmpty()) {
|
||||
// // 로딩 중
|
||||
// Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// CircularProgressIndicator()
|
||||
// }
|
||||
// } else {
|
||||
// // [성공] 리스트 노출
|
||||
// LazyColumn {
|
||||
// itemsIndexed(rankingList) { index, stock ->
|
||||
// RankingItemRow(index, stock, isDomestic, selectedType) {
|
||||
// onSelect(stock.mkrtc_objt_iscd, stock.hts_kor_alph_nm)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//@Composable
|
||||
//fun MarketToggleButton(title: String, isSelected: Boolean, activeColor: Color, onClick: () -> Unit) {
|
||||
// OutlinedButton(
|
||||
// onClick = onClick,
|
||||
// colors = ButtonDefaults.outlinedButtonColors(
|
||||
// backgroundColor = if (isSelected) activeColor.copy(alpha = 0.1f) else Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier.height(36.dp),
|
||||
// border = BorderStroke(1.dp, if (isSelected) activeColor else Color.LightGray)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = title,
|
||||
// color = if (isSelected) activeColor else Color.Gray,
|
||||
// fontSize = 12.sp,
|
||||
// fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
@@ -38,6 +38,7 @@ import kotlin.collections.isNotEmpty
|
||||
fun StockDetailSection(
|
||||
stockCode: String,
|
||||
stockName: String,
|
||||
holdingQuantity : String,
|
||||
isDomestic: Boolean,
|
||||
tradeService: KisTradeService,
|
||||
wsManager: KisWebSocketManager
|
||||
@@ -153,6 +154,7 @@ fun StockDetailSection(
|
||||
previousClose = previousClose,
|
||||
openPrice = openPrice,
|
||||
resultMessage = resultMessage,
|
||||
resultMessageClear = {resultMessage = ""},
|
||||
isSuccess = isSuccess
|
||||
)
|
||||
|
||||
@@ -213,7 +215,10 @@ fun StockDetailSection(
|
||||
Column(modifier = Modifier.weight(0.6f)) {
|
||||
IntegratedOrderSection(
|
||||
stockCode = stockCode,
|
||||
stockName = stockName,
|
||||
isDomestic = isDomestic,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
holdingQuantity = holdingQuantity,
|
||||
tradeService = tradeService,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun StockHeader(
|
||||
name: String,
|
||||
@@ -21,6 +22,7 @@ fun StockHeader(
|
||||
previousClose: String, // 추가: 전일 종가
|
||||
openPrice: String, // 추가: 금일 시가
|
||||
resultMessage: String,
|
||||
resultMessageClear : ()->Unit,
|
||||
isSuccess: Boolean
|
||||
) {
|
||||
Column(modifier = Modifier.wrapContentWidth()) {
|
||||
@@ -28,8 +30,11 @@ fun StockHeader(
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
Surface(
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
onClick = {
|
||||
resultMessageClear.invoke()
|
||||
}
|
||||
) {
|
||||
Text(text = resultMessage, color = Color.White, modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package ui
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 model.CandleData
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
|
||||
|
||||
@Composable
|
||||
fun SummaryGraphCard(label: String, data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier.height(60.dp), elevation = 2.dp, backgroundColor = Color.White) {
|
||||
Column(modifier = Modifier.padding(4.dp)) {
|
||||
Text(label, fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
|
||||
|
||||
if (data.isNotEmpty()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val prices = data.map { it.stck_clpr.toDoubleOrNull() ?: 0.0 }
|
||||
val max = prices.maxOrNull() ?: 1.0
|
||||
val min = prices.minOrNull() ?: 0.0
|
||||
val range = if (max == min) 1.0 else max - min
|
||||
|
||||
val stepX = size.width / (prices.size - 1).coerceAtLeast(1)
|
||||
val points = prices.mapIndexed { i, p ->
|
||||
Offset(i * stepX, (size.height - ((p - min) / range * size.height)).toFloat())
|
||||
}
|
||||
|
||||
// 추세선 그리기
|
||||
for (i in 0 until points.size - 1) {
|
||||
drawLine(
|
||||
color = if (prices.last() >= prices.first()) Color.Red else Color.Blue,
|
||||
start = points[i],
|
||||
end = points[i + 1],
|
||||
strokeWidth = 2f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user