atrade/src/main/kotlin/ui/DashboardScreen.kt
2026-01-10 18:16:50 +09:00

302 lines
11 KiB
Kotlin

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.RankingType
import model.StockHolding
import network.KisTradeService
import network.KisWebSocketManager
import util.MarketUtil
@Composable
fun DashboardScreen(config: AppConfig, token: String) {
val wsManager = remember { KisWebSocketManager(config.isSimulation) }
val tradeService = remember { KisTradeService(config.isSimulation) }
// 전역 상태: 현재 선택된 종목
var selectedStockCode by remember { mutableStateOf("") }
var selectedStockName by remember { mutableStateOf("") }
// 잔고 데이터 상태
var holdings by remember { mutableStateOf<List<StockHolding>>(emptyList()) }
var summary by remember { mutableStateOf<BalanceSummary?>(null) }
// 초기 데이터 로드 및 웹소켓 연결
LaunchedEffect(Unit) {
val approvalKey = tradeService.fetchApprovalKey(config.appKey, config.secretKey)
approvalKey?.let { wsManager.connect(it) }
tradeService.fetchBalance(token, config.appKey, config.secretKey, config.accountNo)
.onSuccess {
holdings = it.output1
summary = it.output2.firstOrNull()
}
}
// 메인 3분할 레이아웃
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
// [좌측 25%] 나의 자산 및 잔고
Column(modifier = Modifier.weight(0.25f).fillMaxHeight().padding(8.dp)) {
Text("나의 잔고", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
BalanceSummaryCard(summary)
Spacer(modifier = Modifier.height(8.dp))
MyStockList(holdings) { code, name ->
selectedStockCode = code
selectedStockName = name
}
}
VerticalDivider()
// [중앙 45%] 실시간 차트 및 주문 (가장 중요)
Column(modifier = Modifier.weight(0.45f).fillMaxHeight().background(Color.White).padding(12.dp)) {
if (selectedStockCode.isNotEmpty()) {
StockDetailArea(config, token, selectedStockCode, selectedStockName, wsManager)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("좌측 잔고나 우측 추천 종목을 클릭하세요", color = Color.Gray)
}
}
}
VerticalDivider()
// [우측 30%] 시장 추천 리스트 (탭 방식)
Column(modifier = Modifier.weight(0.3f).fillMaxHeight().padding(8.dp)) {
Text("시장 추천 TOP 20", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
RecommendationTabs(config, token) { code, name ->
selectedStockCode = code
selectedStockName = name
}
}
}
}
@Composable
fun StockItemRow(stock: StockHolding) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stock.prdt_name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(stock.pdno, fontSize = 12.sp, color = Color.Gray)
}
Column(horizontalAlignment = Alignment.End) {
Text("${stock.prpr}", fontWeight = FontWeight.Bold)
// 수익률에 따른 색상 처리 (웹 소스 format.color 로직 이식)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = when {
rate > 0 -> Color(0xFFE03E2D) // 웹 소스의 빨간색
rate < 0 -> Color(0xFF0E62CF) // 웹 소스의 파란색
else -> Color.DarkGray
}
Text(
text = "${if(rate > 0) "▲" else if(rate < 0) "▼" else ""} ${stock.evlu_pfls_rt}%",
color = color,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
fun RankingItemRow(
index: Int, // 순위 표시를 위해 index 추가
rank: RankingStock,
isDomestic: Boolean,
type: RankingType,
onClick: () -> Unit
) {
val displayColor = when {
type == RankingType.FALL -> Color(0xFF0E62CF) // 하락 탭은 무조건 파랑
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 > 0 -> Color(0xFFE03E2D) // 그 외 양수면 빨강
rank.prdy_ctrt.toDoubleOrNull() ?: 0.0 < 0 -> Color(0xFF0E62CF) // 음수면 파랑
else -> Color.DarkGray
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
elevation = 0.dp,
backgroundColor = Color.White
) {
Row(
modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// [1] 순위 표시 (1~20)
Text(
text = "${index + 1}",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (index < 3) displayColor else Color.Gray, // 1~3위 강조
modifier = Modifier.width(24.dp)
)
// [2] 종목명 및 코드
Column(modifier = Modifier.weight(1f)) {
Text(
text = rank.hts_kor_alph_nm,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = rank.mkrtc_objt_iscd,
fontSize = 11.sp,
color = Color.Gray
)
}
// [3] 등락률 배지
Surface(
color = displayColor.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rank.prdy_ctrt.toDouble() > 0) "+" else ""}${rank.prdy_ctrt}%",
color = displayColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
@Composable
fun VerticalDivider(modifier: Modifier = Modifier) {
Box(modifier.fillMaxHeight().width(1.dp).background(Color.LightGray))
}
@Composable
fun BalanceSummaryCard(summary: BalanceSummary?) {
Card(
elevation = 4.dp,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
backgroundColor = Color(0xFFF8F9FA) // 가벼운 배경색
) {
Column(modifier = Modifier.padding(20.dp)) {
Text("총 평가 자산", style = MaterialTheme.typography.caption, color = Color.Gray)
Text(
text = "${summary?.tot_evlu_amt ?: "0"}",
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold,
color = Color(0xFF333333)
)
Spacer(modifier = Modifier.height(8.dp))
val profitRate = summary?.evlu_pfls_rt?.toDoubleOrNull() ?: 0.0
val profitColor = if (profitRate > 0) Color(0xFFE03E2D) else if (profitRate < 0) Color(0xFF0E62CF) else Color.DarkGray
Row(verticalAlignment = Alignment.CenterVertically) {
Text("실현 수익률: ", style = MaterialTheme.typography.body2)
Text(
text = "${if (profitRate > 0) "+" else ""}$profitRate%",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
color = profitColor
)
}
}
}
}
@Composable
fun StockItemRow(stock: StockHolding, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onClick() },
elevation = 2.dp,
shape = RoundedCornerShape(4.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. 종목명 및 코드 (왼쪽)
Column(modifier = Modifier.weight(1.2f)) {
Text(
text = stock.prdt_name,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stock.pdno,
style = MaterialTheme.typography.caption,
color = Color.Gray
)
}
// 2. 보유 수량 및 현재가 (중앙)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
Text("${stock.hldg_qty}", style = MaterialTheme.typography.body2)
Text(
text = "${stock.prpr}",
style = MaterialTheme.typography.caption,
color = Color.DarkGray
)
}
// 3. 수익률 (오른쪽)
val rate = stock.evlu_pfls_rt.toDoubleOrNull() ?: 0.0
val color = if (rate > 0) Color(0xFFE03E2D) else if (rate < 0) Color(0xFF0E62CF) else Color.DarkGray
Box(
modifier = Modifier.weight(0.8f),
contentAlignment = Alignment.CenterEnd
) {
Surface(
color = color.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${if (rate > 0) "▲" else if (rate < 0) "▼" else ""}${stock.evlu_pfls_rt}%",
color = color,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}