This commit is contained in:
2026-03-13 16:37:53 +09:00
parent 8013e80a34
commit fcd6b5e800
9 changed files with 727 additions and 206 deletions
+2 -15
View File
@@ -58,14 +58,6 @@ fun DashboardScreen() {
var monthSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
var yearSummary by remember { mutableStateOf<MutableList<CandleData>>(mutableListOf()) }
fun setupAutoTradingWatchdog(tradeService: KisTradeService, callback: TradingDecisionCallback) {
CoroutineScope(Dispatchers.Default).launch {
// while (true) {
// delay(60000) // 1분마다 체크
AutoTradingManager.checkAndRestart(tradeService, callback)
// }
}
}
var callback = object : TradingDecisionCallback {
@@ -91,11 +83,6 @@ fun DashboardScreen() {
}
}
LaunchedEffect(Unit) {
// 화면이 완전히 그려지고 안정화될 때까지 1초 대기
delay(1000)
AutoTradingManager.startAutoDiscoveryLoop(tradeService, callback)
}
// 리소스 정리는 여전히 DisposableEffect에서 수행
DisposableEffect(Unit) {
@@ -108,13 +95,13 @@ fun DashboardScreen() {
var refreshTrigger by remember { mutableStateOf(0) }
// [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼
val executionCache = remember { mutableMapOf<String, ExecutionData>() }
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
// [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행
LaunchedEffect(refreshTrigger) {
// setupAutoTradingWatchdog(tradeService,callback)
}
val processingIds = remember { mutableSetOf<String>() } // 주문번호 기준 잠금
suspend fun syncAndExecute(orderNo: String) {
if (processingIds.contains(orderNo)) return
processingIds.add(orderNo)
+3 -56
View File
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import model.ConfigIndex
import model.InvestmentGrade
import model.KisSession
import model.RankingStock
import network.KisTradeService
@@ -30,61 +31,7 @@ import util.MarketUtil
import kotlin.math.min
import kotlin.math.roundToInt
enum class InvestmentGrade(
val displayName: String,
val description: String,
val shortWeight: Double = 0.0,
val midWeight: Double = 0.0,
val longWeight: Double = 0.0,
val profitGuide: ConfigIndex,
val buyGuide: ConfigIndex,
) {
LEVEL_5_STRONG_RECOMMEND(
displayName = "최상급 추천",
description = "단기·중기·장기 모두 우수하고, 신뢰도 매우 높은 범용 매수 추천",
shortWeight = 1.0,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_5_PROFIT,
buyGuide = ConfigIndex.GRADE_5_BUY,
),
LEVEL_4_BALANCED_RECOMMEND(
displayName = "균형 추천",
description = "중기·장기 기본은 양호하고, 단기 성과도 준수한 안정형 추천",
shortWeight = 0.8,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_4_PROFIT,
buyGuide = ConfigIndex.GRADE_4_BUY,
),
LEVEL_3_CAUTIOUS_RECOMMEND(
displayName = "보수적 추천",
description = "중기/장기 기본은 양호하지만, 단기 변동성이 높아 신중히 접근해야 함",
shortWeight = 0.6,
midWeight = 1.0,
longWeight = 1.0,
profitGuide = ConfigIndex.GRADE_3_PROFIT,
buyGuide = ConfigIndex.GRADE_3_BUY,
),
LEVEL_2_HIGH_RISK(
displayName = "고위험 추천",
description = "단기/초단기 성과만 강하고, 중기·장기가 애매하여 리스크가 큰 투자",
shortWeight = 1.0,
midWeight = 0.4,
longWeight = 0.4,
profitGuide = ConfigIndex.GRADE_2_PROFIT,
buyGuide = ConfigIndex.GRADE_2_BUY,
),
LEVEL_1_SPECULATIVE(
displayName = "순수 공격적 선택",
description = "단기/초단기 성과에만 의존하는 단기 급등형 공격적 투자",
shortWeight = 1.0,
midWeight = 0.2,
longWeight = 0.2,
profitGuide = ConfigIndex.GRADE_1_PROFIT,
buyGuide = ConfigIndex.GRADE_1_BUY,
)
}
/**
@@ -316,7 +263,7 @@ fun IntegratedOrderSection(
AutoTradingManager.addToReanalysis(RankingStock(mksc_shrn_iscd = completeTradingDecision.stockCode,hts_kor_isnm = completeTradingDecision.stockName))
println("✋ [관망] 토탈 스코어 또는 신뢰도 미달 이나 약간의 오차로 재분석 대기열에 추가")
} else {
println("✋ [관망] 토탈 스코어(${String.format("%.1f", totalScore)}) 또는 신뢰도 ${completeTradingDecision.confidence} 미달")
println("✋ [관망] 토탈 스코어(${String.format("%.1f[${minScore}]", totalScore)}) 또는 신뢰도 (${String.format("%.1f[${MIN_CONFIDENCE}]", completeTradingDecision.confidence)}) 미달")
}
}
when (completeTradingDecision?.decision) {
+114 -76
View File
@@ -24,6 +24,7 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import model.AppConfig
@@ -53,17 +54,22 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
}
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(24.dp)) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) {
item {
Text("거래 방식 선택", style = MaterialTheme.typography.h6)
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
){
Text("투자 방식", style = MaterialTheme.typography.subtitle1)
Spacer(Modifier.width(10.dp))
RadioButton(selected = !config.isSimulation, onClick = { config = config.copy(isSimulation = false,) })
Text("실전투자")
Spacer(Modifier.width(16.dp))
Text("실전")
Spacer(Modifier.width(10.dp))
RadioButton(selected = config.isSimulation, onClick = { config = config.copy() })
Text("모의투자")
Text("모의")
}
Divider(Modifier.padding(vertical = 12.dp))
Divider(Modifier.padding(vertical = 10.dp))
OutlinedTextField(
value = config.htsId,
onValueChange = { config = config.copy(htsId = it,) },
@@ -73,94 +79,126 @@ fun SettingsScreen(onAuthSuccess: () -> Unit) {
)
// 실전 3종 입력
Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.realAccountNo, onValueChange = {
config = config.copy(realAccountNo = it,)
if(it.length >= 8) checkAndLoadConfig(it, true)
}, label = { Text("실전 계좌번호") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.realAppKey, onValueChange = { config = config.copy(realAppKey = it,) }, label = { Text("실전 App Key") }, modifier = Modifier.fillMaxWidth())
Row(
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
) {
OutlinedTextField(
value = config.realAccountNo, onValueChange = {
config = config.copy(realAccountNo = it,)
if (it.length >= 8) checkAndLoadConfig(it, true)
}, label = { Text("실전 계좌번호") }, modifier = Modifier.weight(0.5f))
OutlinedTextField(
value = config.realAppKey,
onValueChange = { config = config.copy(realAppKey = it,) },
label = { Text("실전 App Key") },
modifier = Modifier.weight(0.5f)
)
}
OutlinedTextField(value = config.realSecretKey, onValueChange = { config = config.copy(realSecretKey = it,) }, label = { Text("실전 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(10.dp))
// 모의 3종 입력
Text("모의투자 정보", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
config = config.copy(vtsAccountNo = it,)
if(it.length >= 8) checkAndLoadConfig(it, false)
}, label = { Text("모의 계좌번호") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.vtsAppKey, onValueChange = { config = config.copy(vtsAppKey = it,) }, label = { Text("모의 App Key") }, modifier = Modifier.fillMaxWidth())
Row(
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
) {
OutlinedTextField(value = config.vtsAccountNo, onValueChange = {
config = config.copy(vtsAccountNo = it,)
if (it.length >= 8) checkAndLoadConfig(it, false)
}, label = { Text("모의 계좌번호") }, modifier = Modifier.weight(0.5f))
OutlinedTextField(
value = config.vtsAppKey,
onValueChange = { config = config.copy(vtsAppKey = it,) },
label = { Text("모의 App Key") },
modifier = Modifier.weight(0.5f)
)
}
OutlinedTextField(value = config.vtsSecretKey, onValueChange = { config = config.copy(vtsSecretKey = it,) }, label = { Text("모의 Secret Key") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
Divider(Modifier.padding(vertical = 16.dp))
Spacer(Modifier.height(10.dp))
Text("정보 조회 Api Keyz", fontWeight = FontWeight.Bold)
OutlinedTextField(value = config.nAppKey, onValueChange = { config = config.copy(nAppKey = it,) }, label = { Text("NAVER Client ID") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = config.nSecretKey, onValueChange = { config = config.copy(nSecretKey = it,) }, label = { Text("NAVER Client Secret") }, modifier = Modifier.fillMaxWidth(), visualTransformation = PasswordVisualTransformation())
OutlinedTextField(value = config.dAppKey, onValueChange = { config = config.copy(dAppKey = it,) }, label = { Text("Dart ApiKey") }, modifier = Modifier.fillMaxWidth())
// AI 모델 경로 및 드래그 앤 드롭
Spacer(Modifier.height(10.dp))
Text("AI 모델 설정", fontWeight = FontWeight.Bold)
Box(
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, 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) config = config.copy(modelPath = path,)
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
Row(
modifier = Modifier.fillMaxWidth(), // 필요에 따라 너비 설정
verticalAlignment = Alignment.CenterVertically // 상하 중앙 정렬 추가
){
Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, 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) config = config.copy(modelPath = path,)
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.modelPath.isEmpty()) "GGUF 모델 파일을 여기로 드래그하세요" else config.modelPath, fontSize = 12.sp)
}
Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:")
if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,)
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp)
}
}
Box(
modifier = Modifier.fillMaxWidth().height(100.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
val embedModelPath = data.readFiles().firstOrNull()?.removePrefix("file:")
if (embedModelPath?.endsWith(".gguf") == true) config = config.copy(embedModelPath = embedModelPath,)
}
}),
contentAlignment = Alignment.Center
) {
Text(if(config.embedModelPath.isEmpty()) "임베드용 GGUF 모델 파일을 여기로 드래그하세요" else config.embedModelPath, fontSize = 12.sp)
}
Spacer(Modifier.height(24.dp))
Spacer(Modifier.height(10.dp))
Button(
modifier = Modifier.fillMaxWidth().height(50.dp),
onClick = {
scope.launch {
// isLoading = true
// 1. KisSession.config 업데이트 및 DB 저장
KisSession.config = config
DatabaseFactory.saveConfig(config)
DartCodeManager.updateCorpCodes(HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true // 기본값이 포함된 요청 바디를 정확히 전송하기 위해 필요
})
}
// [수정] 모든 로그(Headers + Body)를 찍도록 설정
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.BODY
}
})
val authService = KisAuthService
val tradeService = KisTradeService
val authSuccess = authService.refreshAllTokens()
val wsKeySuccess = tradeService.refreshWebsocketKey()
var retryCount = 0
val maxRetries = 3
val retryDelay = 90_000L // 1분 30초
var isAuthCompleted = false
if (authSuccess && wsKeySuccess) {
statusMessage = "✅ 인증 성공! LLM 시작 중..."
onAuthSuccess()
} else {
statusMessage = "❌ 인증 실패. 키 정보를 확인하세요."
while (retryCount <= maxRetries && !isAuthCompleted) {
if (retryCount > 0) {
statusMessage = "⏳ 인증 재시도 중... (${retryCount}/${maxRetries}) - 1분 30초 후 재시작"
delay(retryDelay)
}
// 1. 설정값 저장
KisSession.config = config
DatabaseFactory.saveConfig(config)
// 2. 법인코드 업데이트
DartCodeManager.updateCorpCodes(HttpClient(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
})
// 3. 토큰 및 웹소켓 키 갱신
val authSuccess = KisAuthService.refreshAllTokens()
val wsKeySuccess = KisTradeService.refreshWebsocketKey()
if (authSuccess && wsKeySuccess) {
statusMessage = "✅ 인증 성공! LLM 시작 중..."
isAuthCompleted = true
onAuthSuccess()
} else {
retryCount++
if (retryCount > maxRetries) {
statusMessage = "❌ 인증 실패. 3회 재시도 후 중단되었습니다. 키 정보를 확인하세요."
} else {
statusMessage = "⚠️ 인증 실패. 잠시 후 자동으로 다시 시도합니다. (시도 $retryCount)"
}
}
}
// isLoading = false
}
}
) { Text("설정 저장 및 실행") }
+227
View File
@@ -0,0 +1,227 @@
package ui
import AutoTradeItem
import TradingDecision
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import model.ConfigIndex
import model.KisSession
import service.TechnicalAnalyzer
@Composable
fun TradingDecisionLog() {
Row(modifier = Modifier.fillMaxSize().background(Color(0xFFF2F2F2))) {
Column(modifier = Modifier.weight(0.5f).padding(8.dp).fillMaxHeight().background(Color.White)) {
Text("AI 자동매매 실시간 로그", style = MaterialTheme.typography.h6)
Divider(Modifier.padding(vertical = 8.dp))
LazyColumn(reverseLayout = true) { // 최신 로그가 위로 오게 함
items(TradingLogStore.decisionLogs) { log ->
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
elevation = 2.dp
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text("${log.time} - ${log.stockName}", fontWeight = FontWeight.Bold)
Text(
text = log.decision,
color = if (log.decision == "BUY") Color.Red else Color.Gray,
fontWeight = FontWeight.ExtraBold
)
}
Text("신뢰도: ${log.confidence}%", fontSize = 11.sp)
Text("이유: ${log.reason}", fontSize = 12.sp, color = Color.DarkGray)
}
}
}
}
}
Column(modifier = Modifier.weight(0.5f).padding(6.dp).fillMaxHeight().background(Color.White)) {
LazyVerticalGrid(
columns = GridCells.Fixed(2), // 2열 병렬 배치
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().fillMaxHeight().background(Color.White)
) {
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰 거래 기본 설정",
style = MaterialTheme.typography.subtitle2,
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
)
}
var defaults = arrayOf(
ConfigIndex.TAX_INDEX,
ConfigIndex.PROFIT_INDEX,
ConfigIndex.BUY_WEIGHT_INDEX,
ConfigIndex.MAX_BUDGET_INDEX,
ConfigIndex.MAX_PRICE_INDEX,
ConfigIndex.MIN_PRICE_INDEX,
ConfigIndex.MIN_PURCHASE_SCORE_INDEX,
ConfigIndex.SELL_PROFIT,
ConfigIndex.MAX_COUNT_INDEX,
)
items(defaults.size) { index ->
val configKey = defaults.get(index)
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
// 저장 로직을 공통 함수로 분리
val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0
if (configKey.label.contains("PROFIT")) {
newValue = newValue / KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)
}
KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue")
}
var text = if (configKey.label.contains("PROFIT")) {
"${(localText.toDoubleOrNull() ?: 1.0) * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}"
} else {
localText
}
OutlinedTextField(
value = text,
onValueChange = { localText = it }, // 화면에는 즉시 반영
label = { Text(configKey.label) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
// 2. 포커스를 잃었을 때 저장
if (!focusState.isFocused) {
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true
)
}
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
"💰매수 정책 및 기대 수익률",
style = MaterialTheme.typography.subtitle2,
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
)
}
var defaults2 = arrayOf(
arrayOf(ConfigIndex.GRADE_5_BUY,
ConfigIndex.GRADE_5_PROFIT,),
arrayOf(ConfigIndex.GRADE_4_BUY,
ConfigIndex.GRADE_4_PROFIT,),
arrayOf(ConfigIndex.GRADE_3_BUY,
ConfigIndex.GRADE_3_PROFIT,),
arrayOf(ConfigIndex.GRADE_2_BUY,
ConfigIndex.GRADE_2_PROFIT,),
arrayOf(ConfigIndex.GRADE_1_BUY,
ConfigIndex.GRADE_1_PROFIT,),
)
for (items in defaults2) {
val common = findLongestCommonSubstring(items.first().label,items.last().label)
item(span = { GridItemSpan(maxLineSpan) }) { // 2열을 모두 차지함
Text(
common,
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 10.dp, bottom = 8.dp)
)
}
items(items.size) { index ->
val configKey = items.get(index)
// 1. 키보드 입력을 실시간으로 보여줄 로컬 상태 (String)
var localText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
var labelText by remember(configKey) {
mutableStateOf(KisSession.config.getValues(configKey)?.toString() ?: "")
}
val saveAction = {
var newValue = localText.toDoubleOrNull() ?: 0.0
//
KisSession.config.setValues(configKey, newValue)
DatabaseFactory.saveConfig(KisSession.config)
println("💾 저장됨: ${configKey.label} = $newValue")
labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}"
} else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
}
}
labelText = if (configKey.name.contains("PROFIT")) {
getRemaining(configKey.label,common) + ": 기준율(${KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)}) * 성향별 비율(${KisSession.config.getValues(configKey)}) + 세금제비용(${KisSession.config.getValues(
ConfigIndex.TAX_INDEX)}) = ${(localText.toDouble() * KisSession.config.getValues(ConfigIndex.PROFIT_INDEX)) + KisSession.config.getValues(
ConfigIndex.TAX_INDEX)} "
} else {
getRemaining(configKey.label,common) + ": -${localText} 호가 매수}"
}
OutlinedTextField(
value = localText,
onValueChange = { localText = it }, // 화면에는 즉시 반영
label = { Text(labelText) },
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
// 2. 포커스를 잃었을 때 저장
if (!focusState.isFocused) {
saveAction()
}
},
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Decimal
),
keyboardActions = KeyboardActions(
// 3. 엔터(Done) 키를 눌렀을 때 저장
onDone = {
saveAction()
}
),
singleLine = true
)
}
}
}
}
}
}