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.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.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.DEFAULT 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 import model.KisSession import network.DartCodeManager import network.KisAuthService import network.KisTradeService import org.jetbrains.exposed.sql.deleteAll import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.transactions.transaction // src/main/kotlin/ui/SettingsScreen.kt @OptIn(ExperimentalComposeUiApi::class) @Composable fun SettingsScreen(onAuthSuccess: () -> Unit) { val scope = rememberCoroutineScope() var config by remember { mutableStateOf(KisSession.config) } var statusMessage by remember { mutableStateOf("정보를 입력하세요.") } // 계좌번호 입력 시 데이터 자동 로드 함수 fun checkAndLoadConfig(accountNo: String, isReal: Boolean) { val loaded = DatabaseFactory.findConfigByAccount(accountNo) if (loaded != null) { config = loaded statusMessage = "✅ 기존 데이터를 불러왔습니다." } } LazyColumn(modifier = Modifier.fillMaxSize().padding(20.dp)) { item { 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(10.dp)) RadioButton(selected = config.isSimulation, onClick = { config = config.copy() }) Text("모의") } Divider(Modifier.padding(vertical = 10.dp)) OutlinedTextField( value = config.htsId, onValueChange = { config = config.copy(htsId = it,) }, label = { Text("HTS ID (실시간 체결 통보용)") }, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), placeholder = { Text("한국투자증권 HTS 접속 ID를 입력하세요") } ) // 실전 3종 입력 Text("실전투자 정보 (시세 조회 필수)", fontWeight = FontWeight.Bold) 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(10.dp)) // 모의 3종 입력 Text("모의투자 정보", fontWeight = FontWeight.Bold) 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()) 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()) Spacer(Modifier.height(10.dp)) Text("AI 모델 설정", fontWeight = FontWeight.Bold) 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) } } Spacer(Modifier.height(10.dp)) Button( modifier = Modifier.fillMaxWidth().height(50.dp), onClick = { scope.launch { var retryCount = 0 val maxRetries = 3 val totalDelaySeconds = 90 // 1분 30초 = 90초 var isAuthCompleted = false while (retryCount <= maxRetries && !isAuthCompleted) { // 재시도 시 대기 및 카운트다운 표시 if (retryCount > 0) { for (secondsLeft in totalDelaySeconds downTo 1) { statusMessage = "⚠️ 인증 실패. ${secondsLeft}초 후 자동으로 다시 시도합니다. (시도 ${retryCount}/${maxRetries})" delay(1000L) // 1초 대기 } } statusMessage = if (retryCount == 0) "⏳ 인증 시도 중..." else "⏳ ${retryCount}차 재시도 중..." // 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회 재시도 후 중단되었습니다. 키 정보를 확인하세요." } // 다음 루프에서 카운트다운 진입 } } } } ) { Text("설정 저장 및 실행") } Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp)) } } }