Files
atrade/src/main/kotlin/ui/SettingsScreen.kt
T
2026-05-06 15:53:55 +09:00

402 lines
20 KiB
Kotlin

package ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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
import service.SystemSleepPreventer
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.io.File
import androidx.compose.ui.input.key.*
import network.NewsService
fun getPastedPathFromClipboard(): String? {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
try {
// 1. 파일 자체가 복사된 경우 (탐색기/파인더에서 Ctrl+C / Cmd+C)
if (clipboard.isDataFlavorAvailable(DataFlavor.javaFileListFlavor)) {
val files = clipboard.getData(DataFlavor.javaFileListFlavor) as? List<*>
val file = files?.firstOrNull() as? File
if (file != null) return file.absolutePath
}
// 2. 텍스트(경로 문자열)가 복사된 경우
if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) {
val text = clipboard.getData(DataFlavor.stringFlavor) as? String
if (text != null) {
// 앞뒤 공백, 따옴표, file:// 접두사 등을 깔끔하게 제거
var cleanPath = text.trim('"', '\'', ' ', '\n', '\r')
.removePrefix("file://").removePrefix("file:")
// 윈도우 드라이브 문자 앞 슬래시 제거 (예: /C:/ -> C:/)
if (cleanPath.startsWith("/") && cleanPath.getOrNull(2) == ':') {
cleanPath = cleanPath.drop(1)
}
return cleanPath
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
// src/main/kotlin/ui/SettingsScreen.kt
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SettingsScreen(onAuthSuccess: () -> Unit) {
var isModelBoxFocused by remember { mutableStateOf(false) }
var isEmbedBoxFocused by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var config by remember { mutableStateOf(KisSession.config) }
var statusMessage by remember { mutableStateOf("프로그램 초기화") }
val modelFocusRequester = remember { FocusRequester() }
val embedModelFocusRequester = remember { FocusRequester() }
val authenticateAndStart: suspend () -> Unit = {
var retryCount = 0
val maxRetries = 3
val totalDelaySeconds = 90
var isAuthCompleted = false
while (retryCount <= maxRetries && !isAuthCompleted) {
if (retryCount > 0) {
for (secondsLeft in totalDelaySeconds downTo 1) {
statusMessage = "⚠️ 인증 실패. ${secondsLeft}초 후 자동으로 다시 시도합니다. (시도 ${retryCount}/${maxRetries})"
delay(1000L)
}
}
statusMessage = if (retryCount == 0) "⏳ 인증 시도 중..." else "⏳ ${retryCount}차 재시도 중..."
KisSession.config = config
DatabaseFactory.saveConfig(config)
DartCodeManager.updateCorpCodes(HttpClient(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
})
val authSuccess = KisAuthService.refreshAllTokens()
val wsKeySuccess = KisTradeService.refreshWebsocketKey()
if (authSuccess && wsKeySuccess) {
statusMessage = "✅ 인증 성공! LLM 시작 중..."
isAuthCompleted = true
onAuthSuccess() // 여기서 대시보드로 넘어감
} else {
retryCount++
if (retryCount > maxRetries) {
statusMessage = "❌ 인증 실패. 3회 재시도 후 중단되었습니다."
}
}
}
}
LaunchedEffect(config) {
while (true) {
val now = java.time.LocalTime.now(java.time.ZoneId.of("Asia/Seoul"))
// 08:30 ~ 15:30 사이이고, 키값이 최소한 하나라도 존재할 때 자동 실행
if (now.isAfter(java.time.LocalTime.of(7, 40)) && now.isBefore(java.time.LocalTime.of(18, 0))) {
if (config.realAppKey.isNotEmpty() && config.vtsAppKey.isNotEmpty() && config.embedModelPath.isNotEmpty() && config.modelPath.isNotEmpty()) {
SystemSleepPreventer.wakeDisplay() // 모니터 켜기
statusMessage = "⏰ 자동 실행 시간(08:30)입니다. 시스템을 가동합니다."
authenticateAndStart()
break // 성공하면 루프 탈출
}
}
delay(30_000) // 1분마다 시간 체크
}
}
// 계좌번호 입력 시 데이터 자동 로드 함수
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 {
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))
.border(
width = if (isModelBoxFocused) 2.dp else 1.dp,
color = if (isModelBoxFocused) Color.Blue else Color.Gray,
shape = RoundedCornerShape(8.dp)
)
// 2. Modifier 순서가 매우 중요합니다.
.focusRequester(modelFocusRequester)
.onFocusChanged { focusState ->
isModelBoxFocused = focusState.isFocused // 포커스 여부 저장
if (focusState.isFocused) {
val rawUri = getPastedPathFromClipboard()
println("rawUri $rawUri")
if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) {
var path = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (path.startsWith("/") && path.getOrNull(2) == ':') {
path = path.drop(1)
}
if (path.endsWith(".gguf")) config = config.copy(modelPath = path)
true // 이벤트 소비 처리
}
println("✅ Box에 포커스가 들어왔습니다!") // 로그 확인
}
}
.focusable()
.clickable {
modelFocusRequester.requestFocus()
}
// 3. onKeyEvent 대신 onPreviewKeyEvent 사용! (핵심)
.onPreviewKeyEvent { event ->
// 디버깅용 로그: 어떤 키가 눌리는지 확인
println("키 입력 감지: ${event.key}, type: ${event.type}")
if (event.type == KeyEventType.KeyUp &&
event.key == Key.V &&
(event.isCtrlPressed || event.isMetaPressed)
) {
println("🚀 Ctrl+V 입력 성공!")
val rawUri = getPastedPathFromClipboard()
if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) {
var path = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (path.startsWith("/") && path.getOrNull(2) == ':') {
path = path.drop(1)
}
if (path.endsWith(".gguf")) config = config.copy(embedModelPath = path)
true // 이벤트 소비 처리
}
}
false
}
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
val rawUri = data.readFiles().firstOrNull()
if (rawUri != null) {
// 1. file:// 또는 file: 접두사 제거
var path = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (path.startsWith("/") && path.getOrNull(2) == ':') {
path = path.drop(1)
}
if (path.endsWith(".gguf")) config = config.copy(modelPath = path)
}
}
}),
contentAlignment = Alignment.Center
) {
Text(
if (config.modelPath.isEmpty()) "클릭하여 파란 테두리가 생기면\nCtrl+V 로 붙여넣기 하세요"
else config.modelPath,
fontSize = 12.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Box(
modifier = Modifier.weight(0.5f).height(60.dp).border(1.dp, Color.Gray, RoundedCornerShape(8.dp))
.border(
width = if (isEmbedBoxFocused) 2.dp else 1.dp,
color = if (isEmbedBoxFocused) Color.Blue else Color.Gray,
shape = RoundedCornerShape(8.dp)
)
// 2. Modifier 순서가 매우 중요합니다.
.focusRequester(embedModelFocusRequester)
.onFocusChanged { focusState ->
isEmbedBoxFocused = focusState.isFocused // 포커스 여부 저장
if (focusState.isFocused) {
val rawUri = getPastedPathFromClipboard()
println("rawUri $rawUri")
if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) {
var path = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (path.startsWith("/") && path.getOrNull(2) == ':') {
path = path.drop(1)
}
if (path.endsWith(".gguf")) config = config.copy(modelPath = path)
true // 이벤트 소비 처리
}
println("✅ Box에 포커스가 들어왔습니다!") // 로그 확인
}
}
.focusable()
.clickable {
embedModelFocusRequester.requestFocus()
}
// 4. 키보드 이벤트 (Ctrl+V / Cmd+V) 감지
.onKeyEvent { event ->
println("event >> $event")
// 키를 떼는 순간(KeyUp) && 누른 키가 'V' && (Ctrl 혹은 Cmd 키가 눌린 상태)
if (event.type == KeyEventType.KeyUp &&
event.key == Key.V &&
(event.isCtrlPressed || event.isMetaPressed)
) {
val rawUri = getPastedPathFromClipboard()
println("rawUri >> $rawUri")
if (rawUri != null && rawUri.endsWith(".gguf", ignoreCase = true)) {
var path = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (path.startsWith("/") && path.getOrNull(2) == ':') {
path = path.drop(1)
}
if (path.endsWith(".gguf")) config = config.copy(modelPath = path)
return@onKeyEvent true // 이벤트 소비 처리
}
}
false
}
.onExternalDrag(onDrop = { state ->
val data = state.dragData
if (data is DragData.FilesList) {
val rawUri = data.readFiles().firstOrNull()
if (rawUri != null) {
// 1. file:// 또는 file: 접두사 제거
var embedModelPath = rawUri.removePrefix("file://").removePrefix("file:")
// 2. 윈도우 환경의 드라이브 문자(예: /C:/) 앞의 슬래시 제거
if (embedModelPath.startsWith("/") && embedModelPath.getOrNull(2) == ':') {
embedModelPath = embedModelPath.drop(1)
}
if (embedModelPath.endsWith(".gguf")) 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 {
authenticateAndStart()
}
}
) { Text("설정 저장 및 실행") }
Text(statusMessage, color = Color.Gray, modifier = Modifier.padding(top = 8.dp))
}
}
}