.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.*
|
||||
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
|
||||
import kotlinx.coroutines.launch
|
||||
import model.RealTimeTrade
|
||||
import network.AiService
|
||||
|
||||
@Composable
|
||||
fun AiAnalysisView(stockName: String, currentPrice: String, trades: List<RealTimeTrade>) {
|
||||
var aiOpinion by remember { mutableStateOf("분석 대기 중...") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 1. 모델 경로 유효성 체크
|
||||
val isModelConfigured = remember {
|
||||
val path = util.AppConfigManager.modelPath
|
||||
path.isNotEmpty() && java.io.File(path).exists()
|
||||
}
|
||||
|
||||
Card(
|
||||
elevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
backgroundColor = if (isModelConfigured) Color(0xFFF1F3F4) else Color(0xFFFFEBEE)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (isModelConfigured) "🤖 AI 투자 전략" else "⚠️ AI 설정 필요",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isModelConfigured) Color(0xFF1A73E8) else Color.Red
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// 2. 경로가 정상일 때만 버튼 활성화
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
aiOpinion = "Gemma가 데이터를 읽고 있습니다..."
|
||||
aiOpinion = network.AiService.fetchAnalysis(stockName, currentPrice, trades)
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
enabled = isModelConfigured && !isLoading, // 유효성 체크 반영
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = Color.White,
|
||||
disabledBackgroundColor = Color(0xFFE0E0E0)
|
||||
)
|
||||
) {
|
||||
Text(if (isLoading) "분석 중" else "분석 실행", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isModelConfigured) {
|
||||
Text(
|
||||
"설정에서 .gguf 모델 파일을 먼저 등록해주세요.",
|
||||
color = Color.Red,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
} else {
|
||||
Divider(Modifier.padding(vertical = 8.dp))
|
||||
Text(text = aiOpinion, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import model.CandleData
|
||||
|
||||
@Composable
|
||||
fun CandleChart(data: List<CandleData>, modifier: Modifier = Modifier) {
|
||||
if (data.isEmpty()) return
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val candleCount = data.size
|
||||
val candleWidth = width / candleCount
|
||||
val spacing = candleWidth * 0.2f // 캔들 사이 간격
|
||||
|
||||
// 1. 가격 범위 계산 (스케일링용)
|
||||
val maxPrice = data.maxOf { it.stck_hgpr.toDouble() }
|
||||
val minPrice = data.minOf { it.stck_lwpr.toDouble() }
|
||||
val priceRange = maxPrice - minPrice
|
||||
|
||||
fun getY(price: Double): Float {
|
||||
return (height - ((price - minPrice) / priceRange * height)).toFloat()
|
||||
}
|
||||
|
||||
data.forEachIndexed { index, candle ->
|
||||
val open = candle.stck_oprc.toDouble()
|
||||
val close = candle.stck_clpr.toDouble()
|
||||
val high = candle.stck_hgpr.toDouble()
|
||||
val low = candle.stck_lwpr.toDouble()
|
||||
|
||||
val isRising = close >= open
|
||||
val color = if (isRising) Color(0xFFE03E2D) else Color(0xFF0E62CF)
|
||||
|
||||
val x = index * candleWidth + spacing / 2
|
||||
val currentCandleWidth = candleWidth - spacing
|
||||
|
||||
// 2. 꼬리 그리기 (High-Low Line)
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(x + currentCandleWidth / 2, getY(high)),
|
||||
end = Offset(x + currentCandleWidth / 2, getY(low)),
|
||||
strokeWidth = 2f
|
||||
)
|
||||
|
||||
// 3. 몸통 그리기 (Open-Close Rect)
|
||||
val bodyTop = getY(maxOf(open, close))
|
||||
val bodyBottom = getY(minOf(open, close))
|
||||
val bodyHeight = maxOf(bodyBottom - bodyTop, 1f) // 최소 1픽셀 보장
|
||||
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x, bodyTop),
|
||||
size = Size(currentCandleWidth, bodyHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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(
|
||||
config: AppConfig,
|
||||
token: String,
|
||||
stockCode: String,
|
||||
currentPrice: String,
|
||||
onOrderResult: (String, Boolean) -> Unit // 결과 메시지와 성공 여부 전달
|
||||
) {
|
||||
val scope = rememberCoroutineScope() // 에러 해결: scope 정의
|
||||
val tradeService = remember { KisTradeService(config.isSimulation) } // 에러 해결: 서비스 정의
|
||||
var orderQty by remember { mutableStateOf("1") }
|
||||
var orderPrice by remember { mutableStateOf("0") } // 0은 시장가
|
||||
var isSubmitting by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFF8F9FA))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// 수량 입력
|
||||
OutlinedTextField(
|
||||
value = orderQty,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderQty = it },
|
||||
label = { Text("수량", fontSize = 10.sp) },
|
||||
modifier = Modifier.width(100.dp).height(50.dp),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// 가격 입력 (시장가 체크박스 기능 포함 가능)
|
||||
OutlinedTextField(
|
||||
value = if(orderPrice == "0") "시장가" else orderPrice,
|
||||
onValueChange = { if(it.all { c -> c.isDigit() }) orderPrice = it },
|
||||
label = { Text("가격", fontSize = 10.sp) },
|
||||
modifier = Modifier.weight(1f).height(50.dp),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// 매수 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
isSubmitting = true
|
||||
scope.launch {
|
||||
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, true)
|
||||
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
isSubmitting = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(45.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFE03E2D)),
|
||||
enabled = !isSubmitting
|
||||
) {
|
||||
Text("현금매수", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
// 매도 버튼
|
||||
Button(
|
||||
onClick = {
|
||||
isSubmitting = true
|
||||
scope.launch {
|
||||
val res = tradeService.postOrder(token, config, stockCode, orderQty, orderPrice, false)
|
||||
res.onSuccess { onOrderResult(it, true) }.onFailure { onOrderResult(it.message ?: "에러", false) }
|
||||
isSubmitting = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(45.dp),
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFF0E62CF)),
|
||||
enabled = !isSubmitting
|
||||
) {
|
||||
Text("현금매도", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.onExternalDrag
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import model.AppConfig
|
||||
import network.KisAuthService
|
||||
import org.jetbrains.exposed.sql.deleteAll
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog // 파일 선택기용
|
||||
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
initialConfig: AppConfig, // 모델 경로가 포함된 확장된 AppConfig 필요
|
||||
onAuthSuccess: (AppConfig, String) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val authService = remember { KisAuthService() }
|
||||
|
||||
// 화면 입력 상태값
|
||||
var appKey by remember { mutableStateOf(initialConfig.appKey) }
|
||||
var secretKey by remember { mutableStateOf(initialConfig.secretKey) }
|
||||
var accountNo by remember { mutableStateOf(initialConfig.accountNo) }
|
||||
var isSimulation by remember { mutableStateOf(initialConfig.isSimulation) }
|
||||
var modelPath by remember { mutableStateOf(initialConfig.modelPath ?: "") } // AI 모델 경로
|
||||
|
||||
var statusMessage by remember { mutableStateOf("설정 정보를 입력하세요.") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
item {
|
||||
Text("API 및 계좌 설정", style = MaterialTheme.typography.h6)
|
||||
OutlinedTextField(value = appKey, onValueChange = { appKey = it }, label = { Text("App Key") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = secretKey, onValueChange = { secretKey = it }, label = { Text("Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
|
||||
OutlinedTextField(value = accountNo, onValueChange = { accountNo = it }, label = { Text("계좌번호") }, modifier = Modifier.fillMaxWidth())
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isSimulation, onCheckedChange = { isSimulation = it })
|
||||
Text("모의투자 서버 사용")
|
||||
}
|
||||
|
||||
Divider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// --- 추가된 AI 모델 설정 섹션 ---
|
||||
Text("AI 모델 설정 (Gemma-2-9b)", style = MaterialTheme.typography.h6)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = modelPath,
|
||||
onValueChange = { modelPath = it },
|
||||
label = { Text("GGUF 모델 경로") },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("파일을 선택하거나 드래그하세요") }
|
||||
)
|
||||
IconButton(onClick = {
|
||||
val chooser = JFileChooser().apply {
|
||||
fileFilter = FileNameExtensionFilter("GGUF 모델", "gguf")
|
||||
}
|
||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
modelPath = chooser.selectedFile.absolutePath
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = "파일 선택")
|
||||
}
|
||||
}
|
||||
|
||||
// 드래그 앤 드롭 영역
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.padding(top = 8.dp)
|
||||
.border(1.dp, Color.LightGray, RoundedCornerShape(8.dp))
|
||||
.onExternalDrag(onDrop = { state ->
|
||||
val data = state.dragData
|
||||
if (data is DragData.FilesList) {
|
||||
val path = data.readFiles().firstOrNull()?.removePrefix("file:")
|
||||
if (path?.endsWith(".gguf") == true) modelPath = path
|
||||
}
|
||||
}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("여기에 .gguf 파일을 드래그하여 놓으세요", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 저장 및 접속 버튼
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
enabled = !isLoading,
|
||||
onClick = {
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
// 1. 새로운 설정 객체 생성 (순서 주의: isSimulation 다음 modelPath)
|
||||
val config = AppConfig(
|
||||
appKey = appKey.trim(),
|
||||
secretKey = secretKey.trim(),
|
||||
accountNo = accountNo.trim(),
|
||||
isSimulation = isSimulation,
|
||||
modelPath = modelPath
|
||||
)
|
||||
|
||||
transaction {
|
||||
ConfigTable.deleteAll()
|
||||
ConfigTable.insert {
|
||||
it[ConfigTable.appKey] = config.appKey
|
||||
it[ConfigTable.secretKey] = config.secretKey
|
||||
it[ConfigTable.accountNo] = config.accountNo
|
||||
it[ConfigTable.isSimulation] = config.isSimulation
|
||||
it[ConfigTable.modelPath] = config.modelPath
|
||||
}
|
||||
}
|
||||
|
||||
statusMessage = "인증 토큰 발급 시도 중..."
|
||||
authService.fetchAccessToken(appKey, secretKey, isSimulation)
|
||||
.onSuccess { response ->
|
||||
statusMessage = "✅ 인증 성공!"
|
||||
onAuthSuccess(config, response.access_token)
|
||||
}.onFailure {
|
||||
statusMessage = "❌ 인증 실패(정보 저장됨): ${it.localizedMessage}"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isLoading) {
|
||||
// [수정된 프로그래스 바] size -> Modifier.size
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("설정 저장 및 접속 시작")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(statusMessage, color = if (statusMessage.contains("✅")) Color.Green else Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 StockDetailArea(
|
||||
config: AppConfig,
|
||||
token: String,
|
||||
code: String,
|
||||
name: String,
|
||||
wsManager: KisWebSocketManager // 매니저 수신
|
||||
) {
|
||||
val currentPrice by wsManager.currentPrice
|
||||
val priceColor by wsManager.priceChangeColor
|
||||
val tradeLogs = wsManager.tradeLogs // Manager의 상태를 직접 참조
|
||||
val tradeService = remember { KisTradeService(config.isSimulation) }
|
||||
var chartData by remember { mutableStateOf<List<CandleData>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var resultMessage by remember { mutableStateOf("") }
|
||||
var isSuccess by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(code) {
|
||||
if (code.isEmpty()) return@LaunchedEffect
|
||||
|
||||
isLoading = true
|
||||
if (code.isNotEmpty()) {
|
||||
// 기존 종목 구독 해지 및 새 종목 구독 메시지 전송
|
||||
// (KisWebSocketManager에 해당 기능을 하는 함수를 만들어서 호출)
|
||||
wsManager.subscribeStock(code)
|
||||
}
|
||||
// 종목 코드 판별 (숫자 6자리면 국내, 아니면 해외로 간주)
|
||||
val isDomestic = code.all { it.isDigit() } && code.length == 6
|
||||
|
||||
val result = if (isDomestic) {
|
||||
tradeService.fetchChartData(token, config.appKey, config.secretKey, code)
|
||||
.map { it.output2.reversed() }
|
||||
} else {
|
||||
// 해외 주식 처리 (우선 NAS 나스닥 기준으로 호출)
|
||||
tradeService.fetchOverseasChartData(token, config.appKey, config.secretKey, code)
|
||||
}
|
||||
|
||||
result.onSuccess { chartData = it }
|
||||
.onFailure { println("차트 로드 실패: ${it.message}") }
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(resultMessage) {
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
delay(3000)
|
||||
resultMessage = ""
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// [상단 정보] 국내/해외 구분 배지 추가
|
||||
if (resultMessage.isNotEmpty()) {
|
||||
Surface(
|
||||
color = if (isSuccess) Color(0xFF4CAF50) else Color(0xFFF44336),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = resultMessage,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val isDomestic = code.all { it.isDigit() } && code.length == 6
|
||||
Badge(backgroundColor = if (isDomestic) Color(0xFFE03E2D) else Color(0xFF0E62CF)) {
|
||||
Text(if (isDomestic) "국내" else "해외", color = Color.White, fontSize = 10.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(name, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
|
||||
Text(" ($code)", color = Color.Gray)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// [차트 영역] CandleChart 컴포저블 재사용
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(350.dp),
|
||||
backgroundColor = Color(0xFF121212)
|
||||
) {
|
||||
if (isLoading) {
|
||||
Box(contentAlignment = Alignment.Center) { CircularProgressIndicator(color = Color.White) }
|
||||
} else if (chartData.isNotEmpty()) {
|
||||
CandleChart(data = chartData, modifier = Modifier.padding(16.dp))
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) { Text("데이터가 없습니다.", color = Color.Gray) }
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AiAnalysisView(
|
||||
stockName = name,
|
||||
currentPrice = wsManager.currentPrice.value,
|
||||
trades = wsManager.tradeLogs
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
// 웹 소스 스타일의 주문 박스
|
||||
// Card(modifier = Modifier.fillMaxWidth(), backgroundColor = Color(0xFFF8F9FA)) {
|
||||
// Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Text("주문 설정", fontWeight = FontWeight.Bold)
|
||||
// // 수량 입력, 매수/매도 버튼 배치 (detail.html 참고)
|
||||
// Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Button(onClick = { /* 매수 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFFE03E2D))) {
|
||||
// Text("매수", color = Color.White)
|
||||
// }
|
||||
// Button(onClick = { /* 매도 */ }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(Color(0xFF0E62CF))) {
|
||||
// Text("매도", color = Color.White)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Column(modifier = Modifier.weight(0.4f)) {
|
||||
Text("실시간 체결", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold)
|
||||
|
||||
// 헤더 영역
|
||||
Row(modifier = Modifier.fillMaxWidth().background(Color(0xFFEEEEEE)).padding(vertical = 4.dp)) {
|
||||
Text("시간", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
Text("체결가", modifier = Modifier.weight(1.5f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
Text("대비", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
Text("체결량", modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 11.sp)
|
||||
}
|
||||
|
||||
// 실시간 리스트
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(tradeLogs) { trade ->
|
||||
TradeLogRow(trade)
|
||||
Divider(color = Color(0xFFF5F5F5))
|
||||
}
|
||||
}
|
||||
}
|
||||
OrderSection(
|
||||
config = config,
|
||||
token = token,
|
||||
stockCode = code,
|
||||
currentPrice = currentPrice,
|
||||
onOrderResult = { msg, success ->
|
||||
resultMessage = msg
|
||||
isSuccess = success
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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.RankingStock
|
||||
import model.RealTimeTrade
|
||||
import model.StockHolding
|
||||
import model.TradeType
|
||||
import network.KisTradeService
|
||||
import util.MarketUtil
|
||||
|
||||
@Composable
|
||||
fun TradeLogRow(trade: RealTimeTrade) {
|
||||
val color = when (trade.type) {
|
||||
TradeType.BUY -> Color(0xFFE03E2D)
|
||||
TradeType.SELL -> Color(0xFF0E62CF)
|
||||
else -> Color.DarkGray
|
||||
}
|
||||
|
||||
// 대량 체결(예: 1000주 이상) 시 연한 배경색 강조
|
||||
val isLargeTrade = (trade.volume.replace(",", "").toIntOrNull() ?: 0) >= 1000
|
||||
val rowBgColor = if (isLargeTrade) color.copy(alpha = 0.05f) else Color.Transparent
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(rowBgColor).padding(vertical = 6.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(trade.time, modifier = Modifier.weight(1f), fontSize = 12.sp, color = Color.Gray, textAlign = TextAlign.Center)
|
||||
Text(trade.price, modifier = Modifier.weight(1.5f), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = color, textAlign = TextAlign.End)
|
||||
Text(trade.change, modifier = Modifier.weight(1f), fontSize = 11.sp, color = color, textAlign = TextAlign.End)
|
||||
Text(
|
||||
text = trade.volume,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isLargeTrade) FontWeight.ExtraBold else FontWeight.Normal,
|
||||
color = color,
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user