// ui/tabs/tabs.kt package ui.tabs import androidx.compose.foundation.HorizontalScrollbar import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.ImageLoader import coil3.compose.rememberAsyncImagePainter import models.SearchResult import utils.Strings import java.io.File @Composable fun ScrapBasedPostTab( isLoading: Boolean, keywords: List, searchResults: List, scrapedFiles: List, selectedFiles: Set, viewedFileContent: String, combinedImagesFromSelectedFiles: List, currentSelectedImages: Set, userPrompt: String, imageLoader: ImageLoader, manualKeyword: String, userScrapComment: String, userMainTopic: String, onManualKeywordChange: (String) -> Unit, onUserPromptChange: (String) -> Unit, onUserScrapCommentChange: (String) -> Unit, onUserMainTopicChange: (String) -> Unit, onFetchTrends: () -> Unit, onKeywordSelect: (String) -> Unit, onSearchResultSelect: (SearchResult) -> Unit, onRefreshFiles: () -> Unit, onFileSelectToggle: (File, Boolean) -> Unit, onFileView: (File) -> Unit, onImageSelect: (String) -> Unit, onSaveChanges: () -> Unit, onGeneratePost: () -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) { // 1. 키워드 및 검색 Column(modifier = Modifier.weight(1.5f).border(1.dp, Color.LightGray).padding(4.dp)) { Row(modifier = Modifier.fillMaxWidth()) { OutlinedTextField(value = manualKeyword, onValueChange = onManualKeywordChange, label = { Text(Strings.LABEL_KEYWORD_INPUT) }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading) Spacer(Modifier.width(4.dp)) Button(onClick = { onKeywordSelect(manualKeyword) }, enabled = !isLoading && manualKeyword.isNotBlank()) { Text(Strings.BUTTON_SEARCH) } } Spacer(Modifier.height(8.dp)) Button(onClick = onFetchTrends, modifier = Modifier.fillMaxWidth(), enabled = !isLoading) { Text(Strings.BUTTON_FETCH_TRENDS) } Divider(modifier = Modifier.padding(vertical = 8.dp)) LazyColumn(modifier = Modifier.weight(1f)) { items(keywords) { keyword -> Text(keyword, modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onKeywordSelect(keyword) }.padding(8.dp)) } } } // 2. 검색 결과 및 스크랩 Column(modifier = Modifier.weight(2f).border(1.dp, Color.LightGray).padding(4.dp)) { Text(Strings.TITLE_SEARCH_RESULTS, style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) LazyColumn(modifier = Modifier.fillMaxSize()) { items(searchResults) { result -> Column(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onSearchResultSelect(result) }.padding(8.dp)) { Text(result.title, style = MaterialTheme.typography.subtitle1, color = MaterialTheme.colors.primary) Text(result.url, style = MaterialTheme.typography.caption, maxLines = 1) } } } } // 3. 파일 관리 및 생성 Column(modifier = Modifier.weight(3f).padding(horizontal = 4.dp).verticalScroll(rememberScrollState())) { // 저장된 파일 Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(Strings.TITLE_SAVED_FILES, style = MaterialTheme.typography.h6, modifier = Modifier.weight(1f).padding(4.dp)) Button(onClick = onRefreshFiles, enabled = !isLoading) { Text(Strings.BUTTON_REFRESH) } } Box(modifier = Modifier.heightIn(max = 200.dp)) { LazyColumn { items(scrapedFiles) { file -> Row(modifier = Modifier.fillMaxWidth().clickable { if (!isLoading) onFileView(file) }.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = file in selectedFiles, onCheckedChange = { isChecked -> onFileSelectToggle(file, isChecked) }, enabled = !isLoading) Text(file.name, modifier = Modifier.padding(start = 4.dp), maxLines = 1) } } } } Spacer(Modifier.height(8.dp)) Text(Strings.TITLE_FILE_CONTENT, style = MaterialTheme.typography.subtitle1) Text(text = viewedFileContent, modifier = Modifier.height(100.dp).fillMaxWidth().border(1.dp, Color.LightGray).padding(4.dp).verticalScroll(rememberScrollState()), style = MaterialTheme.typography.body2) Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text(Strings.TITLE_SELECT_IMAGES, style = MaterialTheme.typography.subtitle1, modifier = Modifier.weight(1f)) Button(onClick = onSaveChanges, enabled = !isLoading && combinedImagesFromSelectedFiles.isNotEmpty()) { Text(Strings.BUTTON_SAVE_IMAGE_SELECTION) } } // ⭐️ [수정] 스크롤바를 표시하기 위해 Box로 감싸고 HorizontalScrollbar 추가 val imageListState = rememberLazyListState() Box(modifier = Modifier.fillMaxWidth().height(120.dp).border(1.dp, Color.LightGray)) { LazyRow( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), // 스크롤바 공간 확보 state = imageListState ) { items(combinedImagesFromSelectedFiles) { imageUrl -> val isSelected = imageUrl in currentSelectedImages Box(modifier = Modifier.padding(4.dp)) { Image(painter = rememberAsyncImagePainter(model = imageUrl, imageLoader = imageLoader), contentDescription = "Scraped Image", modifier = Modifier.size(100.dp).clickable { onImageSelect(imageUrl) }.border(if (isSelected) 4.dp else 0.dp, MaterialTheme.colors.primary), contentScale = ContentScale.Crop) } } } HorizontalScrollbar( modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), adapter = rememberScrollbarAdapter(imageListState) ) } } Spacer(Modifier.height(16.dp)) // 글 생성 제어 Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray).padding(8.dp)) { Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6) OutlinedTextField(value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_PROMPT) }) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = userMainTopic, onValueChange = onUserMainTopicChange, modifier = Modifier.fillMaxWidth(), label = { Text(Strings.LABEL_MAIN_TOPIC) }, singleLine = true ) Spacer(Modifier.height(8.dp)) OutlinedTextField(value = userScrapComment, onValueChange = onUserScrapCommentChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_USER_COMMENT) }) Spacer(Modifier.height(8.dp)) Button(onClick = onGeneratePost, enabled = !isLoading && selectedFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { Text(Strings.buttonGeneratePost(selectedFiles.size)) } } } } if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } @Composable fun DirectPostTab( isLoading: Boolean, userOwnContent: String, onUserOwnContentChange: (String) -> Unit, uploadedImageFiles: List, imageLoader: ImageLoader, userPrompt: String, onUserPromptChange: (String) -> Unit, onUploadImage: () -> Unit, onRemoveUploadedImage: (File) -> Unit, onGeneratePost: () -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize().padding(8.dp)) { // 1. 내용 작성 Column(modifier = Modifier.weight(2f).padding(end = 8.dp)) { Text(Strings.TITLE_DIRECT_POST, style = MaterialTheme.typography.h6, modifier = Modifier.padding(4.dp)) OutlinedTextField( value = userOwnContent, onValueChange = onUserOwnContentChange, modifier = Modifier.fillMaxSize(), label = { Text(Strings.LABEL_DIRECT_POST_CONTENT) } ) } // 2. 이미지 및 생성 제어 Column(modifier = Modifier.weight(1f).border(1.dp, Color.LightGray).padding(8.dp)) { Text(Strings.TITLE_IMAGE_UPLOAD, style = MaterialTheme.typography.h6) Button(onClick = onUploadImage, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_IMAGE) } Spacer(Modifier.height(8.dp)) // ⭐️ [수정] 스크롤바를 표시하기 위해 Box로 감싸고 HorizontalScrollbar 추가 val imageListState = rememberLazyListState() Box(modifier = Modifier.fillMaxWidth().height(120.dp)) { LazyRow( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), state = imageListState ) { items(uploadedImageFiles) { file -> Box(modifier = Modifier.padding(4.dp)) { Image( painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), contentDescription = "Uploaded Image", modifier = Modifier.size(100.dp).clickable { onRemoveUploadedImage(file) }, contentScale = ContentScale.Crop ) } } } HorizontalScrollbar( modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), adapter = rememberScrollbarAdapter(imageListState) ) } Spacer(Modifier.height(16.dp)) Text(Strings.LABEL_LLM_REQUEST, style = MaterialTheme.typography.h6) OutlinedTextField( value = userPrompt, onValueChange = onUserPromptChange, modifier = Modifier.fillMaxWidth().height(150.dp), label = { Text(Strings.LABEL_USER_PROMPT) } ) Spacer(Modifier.height(8.dp)) Button( onClick = onGeneratePost, enabled = !isLoading && userOwnContent.isNotBlank() && uploadedImageFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth() ) { Text(Strings.BUTTON_GENERATE_POST_DIRECT) } } } if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } @Composable fun ReceiptAnalyzerTab( isLoading: Boolean, receiptFiles: List, receiptAnalysisResult: String, isAnalyzing: Boolean, receiptContextPrompt: String, imageLoader: ImageLoader, onUploadReceipt: () -> Unit, onRemoveReceipt: (File) -> Unit, onReceiptContextPromptChange: (String) -> Unit, onAnalyzeReceipts: () -> Unit, onCancelAnalysis: () -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Text(Strings.TITLE_RECEIPT_ANALYZER, style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 8.dp)) Button(onClick = onUploadReceipt, enabled = !isLoading, modifier = Modifier.fillMaxWidth()) { Text(Strings.BUTTON_UPLOAD_RECEIPT) } Spacer(Modifier.height(8.dp)) // ⭐️ [수정] 스크롤바를 표시하기 위해 Box로 감싸고 HorizontalScrollbar 추가 val imageListState = rememberLazyListState() Box(modifier = Modifier.fillMaxWidth().height(120.dp)) { LazyRow( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), state = imageListState ) { items(receiptFiles) { file -> Box(modifier = Modifier.padding(4.dp)) { Image( painter = rememberAsyncImagePainter(model = file, imageLoader = imageLoader), contentDescription = "Receipt Image", modifier = Modifier.size(100.dp).clickable { onRemoveReceipt(file) }, contentScale = ContentScale.Crop ) } } } HorizontalScrollbar( modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), adapter = rememberScrollbarAdapter(imageListState) ) } Spacer(Modifier.height(16.dp)) OutlinedTextField( value = receiptContextPrompt, onValueChange = onReceiptContextPromptChange, modifier = Modifier.fillMaxWidth(), placeholder = { Text(Strings.PLACEHOLDER_RECEIPT_CONTEXT) }, label = { Text(Strings.LABEL_RECEIPT_CONTEXT) } ) Spacer(Modifier.height(16.dp)) if (isAnalyzing) { Button(onClick = onCancelAnalysis, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) Spacer(Modifier.width(8.dp)) Text(Strings.BUTTON_CANCEL_ANALYSIS) } } } else { Button(onClick = onAnalyzeReceipts, enabled = !isLoading && receiptFiles.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { Text(Strings.buttonStartAnalysis(receiptFiles.size)) } } Spacer(Modifier.height(16.dp)) OutlinedTextField( value = receiptAnalysisResult, onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize(), label = { Text(Strings.LABEL_ANALYSIS_RESULT) } ) } if (isLoading && !isAnalyzing) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } @Composable fun LogTab(logs: List) { TextField(value = logs.joinToString("\n"), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxSize().padding(8.dp)) } @Composable fun ResultTab( result: String, onRequestResultChange: (String) -> Unit, revisionRequest: String, onRevisionRequestChange: (String) -> Unit, isLoading: Boolean, onRevise: () -> Unit, onCopyToClipboard: () -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { OutlinedTextField( value = result, onValueChange = onRequestResultChange, modifier = Modifier.weight(1f).fillMaxWidth(), label = { Text(Strings.LABEL_BLOG_RESULT) } ) Spacer(Modifier.height(16.dp)) OutlinedTextField( value = revisionRequest, onValueChange = onRevisionRequestChange, modifier = Modifier.fillMaxWidth().height(100.dp), label = { Text(Strings.LABEL_REVISION_REQUEST) }, placeholder = { Text(Strings.PLACEHOLDER_REVISION_REQUEST) }, enabled = !isLoading ) Spacer(Modifier.height(8.dp)) Row(modifier = Modifier.fillMaxWidth()) { Button( onClick = onRevise, enabled = !isLoading && revisionRequest.isNotBlank(), modifier = Modifier.weight(1f) ) { if (isLoading) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colors.onPrimary, strokeWidth = 2.dp) } else { Text(Strings.BUTTON_REVISE_POST) } } Spacer(Modifier.width(8.dp)) Button( onClick = onCopyToClipboard, enabled = !isLoading && result.isNotBlank(), modifier = Modifier.weight(1f) ) { Text(Strings.BUTTON_COPY_TO_CLIPBOARD) } } } } } @Composable fun SettingsTab( generatePromptPrefix: String, onGeneratePromptPrefixChange: (String) -> Unit, generatePromptInstructions: List, onGeneratePromptInstructionsChange: (List) -> Unit, revisePrompt: String, onRevisePromptChange: (String) -> Unit, receiptPrompt: String, onReceiptPromptChange: (String) -> Unit, articleSelectors: List, onArticleSelectorsChange: (List) -> Unit, onSave: () -> Unit, onReset: () -> Unit, isLoading: Boolean ) { Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp) .verticalScroll(rememberScrollState()) ) { Text("프롬프트 및 설정", style = MaterialTheme.typography.h5, modifier = Modifier.padding(bottom = 16.dp)) // --- 블로그 글 생성 프롬프트 섹션 --- Text("블로그 글 생성 프롬프트", style = MaterialTheme.typography.h6) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = generatePromptPrefix, onValueChange = onGeneratePromptPrefixChange, modifier = Modifier.fillMaxWidth().height(150.dp), label = { Text("기본 역할 (Prefix)") }, enabled = !isLoading ) Spacer(Modifier.height(8.dp)) Text("요청사항 목록", style = MaterialTheme.typography.subtitle1) // LazyColumn은 Column 내에서 높이가 지정되어야 하므로 Box로 감싸서 제한 Box(modifier = Modifier.heightIn(max = 250.dp)) { LazyColumn(modifier = Modifier.fillMaxWidth()) { itemsIndexed(generatePromptInstructions) { index, instruction -> Row( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = instruction, onValueChange = { newText -> val newList = generatePromptInstructions.toMutableList() newList[index] = newText onGeneratePromptInstructionsChange(newList) }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading ) Spacer(Modifier.width(8.dp)) IconButton(onClick = { val newList = generatePromptInstructions.toMutableList() newList.removeAt(index) onGeneratePromptInstructionsChange(newList) }, enabled = !isLoading) { Icon(Icons.Default.Delete, contentDescription = "삭제") } } } } } Button( onClick = { onGeneratePromptInstructionsChange(generatePromptInstructions + "") }, enabled = !isLoading ) { Text("요청사항 항목 추가") } Divider(modifier = Modifier.padding(vertical = 16.dp)) // ⭐️ [추가] 스크래핑 CSS 셀렉터 설정 UI Text("아티클 스크래핑 CSS 셀렉터", style = MaterialTheme.typography.h6) Text( "스크랩 시 본문을 찾기 위해 사용되는 CSS 셀렉터 목록입니다. 우선순위가 높은 것을 위로 배치하세요.", style = MaterialTheme.typography.caption, modifier = Modifier.padding(bottom = 8.dp) ) Box(modifier = Modifier.heightIn(max = 250.dp)) { LazyColumn(modifier = Modifier.fillMaxWidth()) { itemsIndexed(articleSelectors) { index, selector -> Row( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = selector, onValueChange = { newText -> val newList = articleSelectors.toMutableList() newList[index] = newText onArticleSelectorsChange(newList) }, modifier = Modifier.weight(1f), singleLine = true, enabled = !isLoading ) Spacer(Modifier.width(8.dp)) IconButton(onClick = { val newList = articleSelectors.toMutableList() newList.removeAt(index) onArticleSelectorsChange(newList) }, enabled = !isLoading) { Icon(Icons.Default.Delete, contentDescription = "셀렉터 삭제") } } } } } Button( onClick = { onArticleSelectorsChange(articleSelectors + "") }, enabled = !isLoading ) { Text("셀렉터 항목 추가") } Divider(modifier = Modifier.padding(vertical = 16.dp)) // --- 글 수정 및 영수증 분석 프롬프트 --- OutlinedTextField( value = revisePrompt, onValueChange = onRevisePromptChange, modifier = Modifier.fillMaxWidth().height(150.dp), label = { Text("블로그 글 수정 프롬프트") }, enabled = !isLoading ) Spacer(Modifier.height(16.dp)) OutlinedTextField( value = receiptPrompt, onValueChange = onReceiptPromptChange, modifier = Modifier.fillMaxWidth().height(150.dp), label = { Text("영수증 분석 프롬프트") }, enabled = !isLoading ) Spacer(Modifier.height(24.dp)) // --- 저장 및 초기화 버튼 --- Row(modifier = Modifier.fillMaxWidth()) { Button(onClick = onSave, enabled = !isLoading, modifier = Modifier.weight(1f)) { Text("설정 저장하기") } Spacer(Modifier.width(16.dp)) Button( onClick = onReset, enabled = !isLoading, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary) ) { Text("기본값으로 초기화") } } } if (isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } }