344 lines
27 KiB
Kotlin
Raw Normal View History

2025-09-11 15:49:10 +09:00
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
2025-09-11 16:32:24 +09:00
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
2025-09-11 15:49:10 +09:00
import androidx.compose.foundation.rememberScrollState
2025-09-11 16:32:24 +09:00
import androidx.compose.foundation.selection.selectable
2025-09-11 15:49:10 +09:00
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
2025-09-11 17:19:54 +09:00
import androidx.compose.ui.text.font.FontFamily
2025-09-11 15:49:10 +09:00
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
2025-09-11 16:32:24 +09:00
import io.ktor.client.plugins.logging.*
2025-09-11 15:49:10 +09:00
import io.ktor.client.request.*
2025-09-11 16:32:24 +09:00
import io.ktor.client.statement.*
2025-09-11 15:49:10 +09:00
import io.ktor.http.HttpHeaders
2025-09-11 16:32:24 +09:00
import io.ktor.http.isSuccess
2025-09-11 15:49:10 +09:00
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.awt.FileDialog
import java.awt.Frame
2025-09-11 16:32:24 +09:00
import java.io.ByteArrayInputStream
2025-09-11 15:49:10 +09:00
import java.nio.file.Files
import java.nio.file.Paths
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.text.NumberFormat
2025-09-11 16:32:24 +09:00
import java.time.*
2025-09-11 15:49:10 +09:00
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.prefs.Preferences
2025-09-11 16:32:24 +09:00
import java.util.zip.GZIPInputStream
2025-09-11 15:49:10 +09:00
2025-09-11 16:32:24 +09:00
// --- 상수 및 Enum 정의 ---
2025-09-11 15:49:10 +09:00
private const val ISSUER_ID_KEY = "APP_STORE_ISSUER_ID"
private const val KEY_ID_KEY = "APP_STORE_KEY_ID"
2025-09-11 17:19:54 +09:00
private const val SKU_KEY = "APP_STORE_SKU"
2025-09-11 16:32:24 +09:00
private const val VENDOR_NUMBER_KEY = "APP_STORE_VENDOR_NUMBER"
2025-09-11 17:19:54 +09:00
enum class ReportType { SALES, APP_SPECIFIC_SALES }
2025-09-11 16:32:24 +09:00
// --- 데이터 클래스 정의 ---
2025-09-11 17:19:54 +09:00
// Main.kt
// ⬇️ countryCode: String 필드를 추가합니다.
data class SalesReportRecord(val title: String, val sku: String, val units: Int, val countryCode: String)
2025-09-11 16:32:24 +09:00
// --- 유틸리티 함수 ---
fun loadPrivateKey(path: String): PrivateKey {
val keyBytes = Files.readAllBytes(Paths.get(path)); val keyPem = String(keyBytes).replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replace("\\s".toRegex(), ""); val decoded = Base64.getDecoder().decode(keyPem); val keySpec = PKCS8EncodedKeySpec(decoded); val kf = KeyFactory.getInstance("EC"); return kf.generatePrivate(keySpec)
}
2025-09-11 17:19:54 +09:00
fun createJWT(privateKey: PrivateKey, issuerId: String, keyId: String): String {
val now = Instant.now(); val expire = now.plusSeconds(20 * 60); return Jwts.builder().setHeaderParam("alg", "ES256").setHeaderParam("kid", keyId).setHeaderParam("typ", "JWT").setIssuer(issuerId).setAudience("appstoreconnect-v1").setIssuedAt(Date.from(now)).setExpiration(Date.from(expire)).signWith(privateKey, SignatureAlgorithm.ES256).compact()
2025-09-11 16:32:24 +09:00
}
fun parseSalesReport(tsvData: String): List<SalesReportRecord> {
val records = mutableListOf<SalesReportRecord>()
val lines = tsvData.split("\n").drop(1)
2025-09-11 17:19:54 +09:00
lines.forEach { line ->
if (line.isBlank()) return@forEach
val columns = line.split("\t")
try {
// ⬇️ countryCode를 13번째 열(인덱스 12)에서 가져오도록 수정합니다.
records.add(SalesReportRecord(
title = columns.getOrElse(4) { "N/A" },
sku = columns.getOrElse(2) { "N/A" },
units = columns.getOrElse(7) { "0" }.toIntOrNull() ?: 0,
countryCode = columns.getOrElse(12) { "N/A" }
))
} catch (e: Exception) {
println("TSV 파싱 오류: $line, ${e.message}")
}
}
return records
2025-09-11 15:49:10 +09:00
}
2025-09-11 17:19:54 +09:00
// --- 메인 화면 ---
2025-09-11 15:49:10 +09:00
@Composable
2025-09-11 17:19:54 +09:00
fun App(onSaveTsvRequest: (name: String, tsvData: String) -> Unit) {
val prefs = remember { Preferences.userRoot().node("com.example.combinedreportgenerator") }; val scope = rememberCoroutineScope()
var selectedTab by remember { mutableStateOf(ReportType.SALES) }; var filePath by remember { mutableStateOf("") }; var issuerId by remember { mutableStateOf(prefs.get(ISSUER_ID_KEY, "")) }; var keyId by remember { mutableStateOf(prefs.get(KEY_ID_KEY, "")) }; var error by remember { mutableStateOf<String?>(null) }; val privateKey: PrivateKey? by remember(filePath) { derivedStateOf { if (filePath.isNotBlank()) { try { error = null; loadPrivateKey(filePath) } catch (e: Exception) { error = "개인키 파일 로드 실패: ${e.message}"; null } } else { null } } }
var logText by remember { mutableStateOf("여기에 통신 기록이 표시됩니다.") }
var salesResult by remember { mutableStateOf<List<SalesReportRecord>>(emptyList()) }; var rawTsvReport by remember { mutableStateOf("") }; var statusMessage by remember { mutableStateOf("리포트 조회를 위한 정보를 입력하세요.") }; var totalUnitsBySku by remember { mutableStateOf<Long?>(null) }
// ⬇️ 변경점 1: 로그 기록 방식을 위에서 아래로 쌓이도록 변경합니다.
val client = remember {
HttpClient(CIO) {
install(Logging) {
level = LogLevel.ALL
logger = object : Logger {
override fun log(message: String) {
val timestamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
// 기존 로그에 새로운 로그를 덧붙입니다.
logText += "[$timestamp]\n$message\n\n"
}
}
2025-09-11 16:32:24 +09:00
}
2025-09-11 17:19:54 +09:00
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
2025-09-11 15:49:10 +09:00
}
}
2025-09-11 17:19:54 +09:00
}
DisposableEffect(Unit) { onDispose { client.close() } }
Row(modifier = Modifier.fillMaxSize()) {
SettingsPanel(
modifier = Modifier.width(350.dp), prefs = prefs, selectedTab = selectedTab, onTabSelected = { selectedTab = it }, filePath = filePath, onFilePathChange = { filePath = it }, issuerId = issuerId, onIssuerIdChange = { issuerId = it; prefs.put(ISSUER_ID_KEY, it) }, keyId = keyId, onKeyIdChange = { keyId = it; prefs.put(KEY_ID_KEY, it) }, error = error,
onGenerateRequest = {
scope.launch {
// ⬇️ 변경점 2: API 요청 시작 시, 기존 로그를 모두 지웁니다.
logText = ""
val key = privateKey
if (key == null) { statusMessage = "오류: 개인키를 선택하세요"; return@launch }
try {
val token = createJWT(key, issuerId, keyId)
when (selectedTab) {
ReportType.SALES -> {
val (vendorNumber, freq, period) = it as Triple<String, String, String>
if(vendorNumber.isBlank()) { statusMessage = "오류: Vendor Number를 입력하세요."; return@launch }
statusMessage = "[$period] 전체 판매 리포트 로딩 중..."; salesResult = emptyList(); totalUnitsBySku = null
val response: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/salesReports") { header(HttpHeaders.Accept, "application/a-gzip"); header(HttpHeaders.Authorization, "Bearer $token"); parameter("filter[reportType]", "SALES"); parameter("filter[reportSubType]", "SUMMARY"); parameter("filter[vendorNumber]", vendorNumber); parameter("filter[frequency]", freq); when (freq) { "DAILY", "WEEKLY" -> parameter("filter[reportDate]", period); "MONTHLY" -> parameter("filter[reportMonth]", period); "YEARLY" -> parameter("filter[reportYear]", period) } }
if (!response.status.isSuccess()) throw Exception("API 오류 (${response.status}): ${response.bodyAsText()}")
rawTsvReport = GZIPInputStream(ByteArrayInputStream(response.body())).bufferedReader(Charsets.UTF_8).use { it.readText() }; val parsedRecords = parseSalesReport(rawTsvReport)
// ⬇️ 기존의 groupBy 로직을 삭제하고, 파싱된 원본 데이터를 그대로 사용합니다.
salesResult = parsedRecords
statusMessage = "[$period] 리포트 조회 완료"
}
ReportType.APP_SPECIFIC_SALES -> {
val (vendorNumber, sku, date) = it as Triple<String, String, LocalDate>
if(vendorNumber.isBlank() || sku.isBlank()) { statusMessage = "오류: Vendor Number와 SKU를 입력하세요."; return@launch }
val dateString = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
statusMessage = "[$dateString] 앱별 판매 리포트 로딩 중..."; totalUnitsBySku = null; salesResult = emptyList()
val response: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/salesReports") { header(HttpHeaders.Accept, "application/a-gzip"); header(HttpHeaders.Authorization, "Bearer $token"); parameter("filter[reportType]", "SALES"); parameter("filter[reportSubType]", "SUMMARY"); parameter("filter[vendorNumber]", vendorNumber); parameter("filter[frequency]", "DAILY"); parameter("filter[reportDate]", dateString) }
if (!response.status.isSuccess()) throw Exception("API 오류 (${response.status}): ${response.bodyAsText()}")
rawTsvReport = GZIPInputStream(ByteArrayInputStream(response.body())).bufferedReader(Charsets.UTF_8).use { it.readText() }; val allRecords = parseSalesReport(rawTsvReport)
val filteredRecords = allRecords.filter { it.sku.equals(sku, ignoreCase = true) }
totalUnitsBySku = filteredRecords.sumOf { it.units }.toLong()
statusMessage = if (filteredRecords.isEmpty() && allRecords.isNotEmpty()) "[$dateString] 해당 SKU를 찾을 수 없음" else "[$dateString] 조회 완료"
}
}
} catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" }
}
}
)
2025-09-11 16:32:24 +09:00
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
2025-09-11 17:19:54 +09:00
ResultsPanel(modifier = Modifier.weight(1f), selectedTab = selectedTab, statusMessage = statusMessage, salesResult = salesResult, totalUnitsBySku = totalUnitsBySku, onSaveRequest = { onSaveTsvRequest(it, rawTsvReport) })
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
LogPanel(logText, modifier = Modifier.width(400.dp))
}
}
@Composable
fun SettingsPanel(modifier: Modifier = Modifier, prefs: Preferences, selectedTab: ReportType, onTabSelected: (ReportType) -> Unit, filePath: String, onFilePathChange: (String) -> Unit, issuerId: String, onIssuerIdChange: (String) -> Unit, keyId: String, onKeyIdChange: (String) -> Unit, error: String?, onGenerateRequest: (Any) -> Unit) {
Column(modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp)) {
Text("API Key 정보", style = MaterialTheme.typography.h6); Spacer(Modifier.height(8.dp)); Text("Issuer ID"); TextField(value = issuerId, onValueChange = onIssuerIdChange, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("Key ID"); TextField(value = keyId, onValueChange = onKeyIdChange, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("개인키(.p8) 파일")
Row(verticalAlignment = Alignment.CenterVertically) {
TextField(value = filePath, onValueChange = onFilePathChange, singleLine = true, modifier = Modifier.weight(1f), readOnly = true); Spacer(Modifier.width(8.dp)); Button(onClick = { val dialog = FileDialog(Frame(), "개인키(.p8) 파일을 선택하세요", FileDialog.LOAD).apply { setFile("*.p8"); isVisible = true }; if (dialog.directory != null && dialog.file != null) { onFilePathChange(dialog.directory + dialog.file) } }) { Text("찾기") }
}
if (error != null) { Text(text = error, color = Color.Red, modifier = Modifier.padding(top = 8.dp)) }
Divider(modifier = Modifier.padding(vertical = 16.dp)); TabRow(selectedTabIndex = selectedTab.ordinal) { Tab(selected = selectedTab == ReportType.SALES, onClick = { onTabSelected(ReportType.SALES) }, text = { Text("전체 판매") }); Tab(selected = selectedTab == ReportType.APP_SPECIFIC_SALES, onClick = { onTabSelected(ReportType.APP_SPECIFIC_SALES) }, text = { Text("앱별 판매") }) }; Spacer(Modifier.height(16.dp))
when (selectedTab) {
ReportType.SALES -> SalesReportSettings(prefs, onGenerateRequest)
ReportType.APP_SPECIFIC_SALES -> AppSpecificSalesSettings(prefs, onGenerateRequest)
}
2025-09-11 15:49:10 +09:00
}
}
@Composable
2025-09-11 17:19:54 +09:00
fun SalesReportSettings(prefs: Preferences, onGenerateRequest: (Triple<String, String, String>) -> Unit) {
var vendorNumber by remember { mutableStateOf(prefs.get(VENDOR_NUMBER_KEY, "")) }; val frequencies = listOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY"); var selectedFrequency by remember { mutableStateOf(frequencies.first()) }; var reportPeriod by remember { mutableStateOf(LocalDate.now().minusDays(3).format(DateTimeFormatter.ISO_LOCAL_DATE)) }; var showDatePicker by remember { mutableStateOf(false) }
2025-09-11 16:32:24 +09:00
Text("Vendor Number"); TextField(value = vendorNumber, onValueChange = {vendorNumber = it; prefs.put(VENDOR_NUMBER_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("리포트 빈도")
frequencies.forEach { freq -> Row(Modifier.fillMaxWidth().selectable(selected = (freq == selectedFrequency), onClick = { selectedFrequency = freq }).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = (freq == selectedFrequency), onClick = null); Text(text = freq, modifier = Modifier.padding(start = 8.dp)) } }
2025-09-11 17:19:54 +09:00
Spacer(Modifier.height(8.dp)); Text("조회 기간"); val isCalendarEnabled = selectedFrequency in listOf("DAILY", "WEEKLY")
Box(modifier = if(isCalendarEnabled) Modifier.clickable { showDatePicker = true } else Modifier) { OutlinedTextField(value = reportPeriod, onValueChange = { if (!isCalendarEnabled) reportPeriod = it }, readOnly = isCalendarEnabled, modifier = Modifier.fillMaxWidth(), trailingIcon = { if(isCalendarEnabled) Icon(Icons.Default.DateRange, "날짜 선택") }, enabled = !isCalendarEnabled, colors = TextFieldDefaults.outlinedTextFieldColors(disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled))) }
if (showDatePicker) { SimpleDatePicker(initialDate = try { LocalDate.parse(reportPeriod) } catch(e:Exception){LocalDate.now().minusDays(3)}, onDateSelected = { reportPeriod = it.format(DateTimeFormatter.ISO_LOCAL_DATE); showDatePicker = false }, onDismissRequest = { showDatePicker = false }) }
Text("DAILY/WEEKLY: 달력 사용, MONTHLY: YYYY-MM, YEARLY: YYYY", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp))
Button(onClick = { onGenerateRequest(Triple(vendorNumber, selectedFrequency, reportPeriod)) }, modifier = Modifier.fillMaxWidth()) { Text("전체 판매 리포트 조회") }
2025-09-11 16:32:24 +09:00
}
2025-09-11 15:49:10 +09:00
2025-09-11 16:32:24 +09:00
@Composable
2025-09-11 17:19:54 +09:00
fun AppSpecificSalesSettings(prefs: Preferences, onGenerateRequest: (Triple<String, String, LocalDate>) -> Unit) {
var vendorNumber by remember { mutableStateOf(prefs.get(VENDOR_NUMBER_KEY, "")) }; var sku by remember { mutableStateOf(prefs.get(SKU_KEY, "")) }; var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(3)) }; var showDatePicker by remember { mutableStateOf(false) }
Text("Vendor Number"); TextField(value = vendorNumber, onValueChange = {vendorNumber = it; prefs.put(VENDOR_NUMBER_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("SKU (제품 번호)"); TextField(value = sku, onValueChange = {sku = it; prefs.put(SKU_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("조회 날짜")
Box(modifier = Modifier.clickable { showDatePicker = true }) { OutlinedTextField(value = reportDate.format(DateTimeFormatter.ISO_LOCAL_DATE), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), trailingIcon = { Icon(Icons.Default.DateRange, "날짜 선택") }, enabled = false, colors = TextFieldDefaults.outlinedTextFieldColors(disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled))) }
if (showDatePicker) { SimpleDatePicker(initialDate = reportDate, onDateSelected = { reportDate = it; showDatePicker = false }, onDismissRequest = { showDatePicker = false }) }
Text("3~4일 이전 날짜 권장", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp))
Button(onClick = { onGenerateRequest(Triple(vendorNumber, sku, reportDate)) }, modifier = Modifier.fillMaxWidth()) { Text("앱별 판매(Units) 조회") }
}
@Composable
fun ResultsPanel(modifier: Modifier = Modifier, selectedTab: ReportType, statusMessage: String, salesResult: List<SalesReportRecord>, totalUnitsBySku: Long?, onSaveRequest: (String) -> Unit) {
Column(modifier = modifier.fillMaxHeight().padding(16.dp)) {
when(selectedTab) {
ReportType.SALES -> SalesResultView(statusMessage, salesResult) { onSaveRequest("sales-report-${System.currentTimeMillis()}") }
ReportType.APP_SPECIFIC_SALES -> AppSpecificResultView(statusMessage, totalUnitsBySku) { onSaveRequest("app-sales-report-${System.currentTimeMillis()}") }
2025-09-11 16:32:24 +09:00
}
2025-09-11 15:49:10 +09:00
}
2025-09-11 16:32:24 +09:00
}
2025-09-11 15:49:10 +09:00
2025-09-11 17:19:54 +09:00
// Main.kt
2025-09-11 16:32:24 +09:00
@Composable
2025-09-11 17:19:54 +09:00
fun SalesResultView(statusMessage: String, result: List<SalesReportRecord>, onSaveRequest: () -> Unit) {
Text(statusMessage, style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 8.dp))
if (result.isNotEmpty()) {
// 전체 총합계 (기존과 동일)
Text("총 다운로드 (Units): ${NumberFormat.getInstance().format(result.sumOf { it.units })}", fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp))
// ⬇️ 데이터를 앱별로 그룹화하고 다운로드 수에 따라 정렬하는 로직 추가
val sortedAndGroupedApps = result
.groupBy { it.title } // 1. 앱 이름으로 그룹화
.mapValues { (_, records) ->
records.sortedByDescending { it.units } // 2. 각 그룹 내에서 국가를 다운로드 수로 정렬
}
.toList()
.sortedByDescending { (_, records) -> records.sumOf { it.units } } // 3. 앱 자체를 총 다운로드 수로 정렬
Divider()
// ⬇️ 그룹화된 데이터를 표시하기 위한 LazyColumn 재구성
LazyColumn() {
// 정렬된 앱 리스트를 순회
sortedAndGroupedApps.forEach { (title, records) ->
// --- 1. 앱 제목 헤더 ---
item {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.1f))
.padding(vertical = 8.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = title, style = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold))
}
2025-09-11 16:32:24 +09:00
}
2025-09-11 17:19:54 +09:00
// --- 2. 국가별 상세 내역 ---
items(records) { record ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp)
.padding(start = 16.dp), // 들여쓰기 효과
verticalAlignment = Alignment.CenterVertically
) {
// 국가 코드
Text(text = record.countryCode, modifier = Modifier.weight(1f))
// 다운로드 수
Text(
text = NumberFormat.getInstance().format(record.units),
modifier = Modifier.width(100.dp), // 너비를 고정하여 정렬 유지
textAlign = TextAlign.End
)
2025-09-11 15:49:10 +09:00
}
2025-09-11 17:19:54 +09:00
Divider(color = Color.Gray.copy(alpha = 0.2f), modifier = Modifier.padding(horizontal = 8.dp))
2025-09-11 16:32:24 +09:00
}
2025-09-11 17:19:54 +09:00
// --- 3. 앱별 소계 ---
item {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "앱 소계", style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold)
Spacer(Modifier.width(16.dp))
Text(
text = NumberFormat.getInstance().format(records.sumOf { it.units }),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(100.dp),
textAlign = TextAlign.End
)
}
Divider(thickness = 2.dp, color = Color.Gray) // 앱과 앱 사이를 구분하는 더 두꺼운 구분선
}
}
2025-09-11 15:49:10 +09:00
}
2025-09-11 17:19:54 +09:00
Spacer(Modifier.height(16.dp))
Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth(), enabled = result.isNotEmpty()) {
Text("원본 데이터 다운로드 (TSV)")
}
}
2025-09-11 15:49:10 +09:00
}
@Composable
2025-09-11 17:19:54 +09:00
fun AppSpecificResultView(statusMessage: String, totalUnits: Long?, onSaveRequest: () -> Unit) {
Text(statusMessage, style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 16.dp))
if (totalUnits != null) {
Card(elevation = 4.dp, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text("해당 SKU 총 다운로드 (Units)", style = MaterialTheme.typography.h6); Spacer(Modifier.height(8.dp)); Text(NumberFormat.getInstance().format(totalUnits), style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colors.primary) } }
Spacer(Modifier.height(16.dp)); Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth()) { Text("전체 원본 데이터 다운로드 (TSV)") }
2025-09-11 15:49:10 +09:00
}
}
2025-09-11 17:19:54 +09:00
2025-09-11 15:49:10 +09:00
@Composable
2025-09-11 17:19:54 +09:00
fun LogPanel(logText: String, modifier: Modifier = Modifier) {
val scrollState = rememberScrollState(); Column(modifier = modifier.fillMaxHeight().background(Color(0xFFF5F5F5)).padding(16.dp)) { Text("통신 전문", style = MaterialTheme.typography.h6, modifier = Modifier.padding(bottom = 8.dp)); Divider(); Box(modifier = Modifier.weight(1f).background(Color.White, MaterialTheme.shapes.small).padding(8.dp)) { Text(text = logText, modifier = Modifier.fillMaxSize().verticalScroll(scrollState), fontFamily = FontFamily.Monospace, fontSize = 12.sp) } }
}
2025-09-11 15:49:10 +09:00
2025-09-11 16:32:24 +09:00
@Composable
fun SimpleDatePicker(initialDate: LocalDate, onDateSelected: (LocalDate) -> Unit, onDismissRequest: () -> Unit) {
var displayedYearMonth by remember { mutableStateOf(YearMonth.from(initialDate)) }; Dialog(onDismissRequest = onDismissRequest) { Surface(shape = MaterialTheme.shapes.medium, modifier = Modifier.padding(16.dp).widthIn(max = 320.dp), elevation = 8.dp) { Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Button(onClick = { displayedYearMonth = displayedYearMonth.minusMonths(1) }) { Text("<") }; Text(text = displayedYearMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월")), style = MaterialTheme.typography.h6); Button(onClick = { displayedYearMonth = displayedYearMonth.plusMonths(1) }) { Text(">") } }; Spacer(Modifier.height(16.dp)); Row(modifier = Modifier.fillMaxWidth()) { listOf("", "", "", "", "", "", "").forEach { day -> Text(text = day, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) } }; Spacer(Modifier.height(8.dp)); val daysInMonth = displayedYearMonth.lengthOfMonth(); val firstDayOfMonth = displayedYearMonth.atDay(1).dayOfWeek.value % 7
2025-09-11 17:19:54 +09:00
androidx.compose.foundation.lazy.grid.LazyVerticalGrid(columns = androidx.compose.foundation.lazy.grid.GridCells.Fixed(7), contentPadding = PaddingValues(vertical = 4.dp)) { items(firstDayOfMonth) { Box(Modifier.size(40.dp)) }; items(daysInMonth) { day -> val date = displayedYearMonth.atDay(day + 1); val isSelected = date == initialDate; Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent).clickable { onDateSelected(date) }, contentAlignment = Alignment.Center) { Text(text = "${day + 1}", color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface) } } }
2025-09-11 16:32:24 +09:00
} } }
2025-09-11 15:49:10 +09:00
}
fun main() = application {
2025-09-11 16:32:24 +09:00
val snackbarHostState = remember { SnackbarHostState() }; val scope = rememberCoroutineScope()
Window(onCloseRequest = ::exitApplication, title = "App Store Connect 리포트") {
2025-09-11 15:49:10 +09:00
MaterialTheme {
2025-09-11 16:32:24 +09:00
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
2025-09-11 17:19:54 +09:00
App( onSaveTsvRequest = { name, tsvData -> val dialog = FileDialog(Frame(), "TSV 파일로 저장", FileDialog.SAVE).apply { file = "$name.tsv"; isVisible = true }; if (dialog.directory != null && dialog.file != null) { try { Files.writeString(Paths.get(dialog.directory, dialog.file), tsvData); scope.launch { snackbarHostState.showSnackbar("TSV 파일 저장 완료") } } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("파일 저장 실패: ${e.message}") } } } } )
2025-09-11 16:32:24 +09:00
}
}
2025-09-11 15:49:10 +09:00
}
}
}