diff --git a/build.gradle.kts b/build.gradle.kts index a412a08..6cbb969 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,6 @@ plugins { kotlin("jvm") version "2.0.0" id("org.jetbrains.compose") version "1.8.0" kotlin("plugin.serialization") version "2.0.0" - // ⬇️ 이 줄을 추가해 주세요! id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" } group = "com.example" @@ -20,6 +19,8 @@ repositories { dependencies { implementation(compose.desktop.currentOs) + implementation(compose.desktop.windows_x64) + implementation(compose.desktop.macos_x64) implementation(compose.materialIconsExtended) // JWT 라이브러리 implementation("io.jsonwebtoken:jjwt-api:0.11.5") @@ -30,12 +31,14 @@ dependencies { implementation("io.ktor:ktor-client-core:2.3.11") implementation("io.ktor:ktor-client-cio:2.3.11") implementation("io.ktor:ktor-client-logging:2.3.11") - // ⬇️ Ktor가 JSON을 처리할 수 있도록 도와주는 라이브러리 추가 implementation("io.ktor:ktor-client-content-negotiation:2.3.11") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11") - // ⬇️ Kotlinx Serialization 라이브러리 추가 + // Kotlinx Serialization 라이브러리 implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") + + // ⬇️ SLF4J 로거 경고 해결을 위해 이 줄을 추가해 주세요! + implementation("org.slf4j:slf4j-simple:2.0.13") } kotlin { @@ -55,4 +58,4 @@ compose.desktop { packageName = "AnalyticsReportGenerator" // 앱 패키지 이름 변경 } } -} +} \ No newline at end of file diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index e1adc86..dfe7816 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -2,10 +2,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -32,10 +32,16 @@ import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.request.* import io.ktor.client.statement.* +import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.awt.FileDialog import java.awt.Frame @@ -55,15 +61,90 @@ import java.util.zip.GZIPInputStream // --- 상수 및 Enum 정의 --- private const val ISSUER_ID_KEY = "APP_STORE_ISSUER_ID" private const val KEY_ID_KEY = "APP_STORE_KEY_ID" -private const val SKU_KEY = "APP_STORE_SKU" private const val VENDOR_NUMBER_KEY = "APP_STORE_VENDOR_NUMBER" -enum class ReportType { SALES, APP_SPECIFIC_SALES } +private const val APP_ADAM_ID_KEY = "APP_STORE_ADAM_ID" +enum class ReportType { SALES, APP_ANALYTICS } // --- 데이터 클래스 정의 --- -// Main.kt -// ⬇️ countryCode: String 필드를 추가합니다. -data class SalesReportRecord(val title: String, val sku: String, val units: Int, val countryCode: String) +// #region 판매 리포트 관련 데이터 클래스 +data class SalesReportRecord(val title: String, val sku: String, val units: Int, val countryCode: String, val productTypeIdentifier: String) +data class AppSalesSummary(val title: String, val totalUnits: Int, val installs: Int, val updates: Int, val redownloads: Int, val others: Int, val details: List) +// #endregion + +// #region 앱 분석 API 관련 데이터 클래스 +@Serializable +data class AnalyticsReportRequest(val data: Data) { + @Serializable + data class Data( + val type: String = "analyticsReportRequests", + val attributes: Attributes, + val relationships: Relationships + ) + @Serializable + data class Attributes(val accessType: String = "ONE_TIME_SNAPSHOT") + @Serializable + data class Relationships(val app: App) + @Serializable + data class App(val data: AppData) + @Serializable + data class AppData(val type: String = "apps", val id: String) +} + +@Serializable +data class AnalyticsReportRequestResponse(val data: ResponseData) { + @Serializable + data class ResponseData(val id: String) +} + +@Serializable +data class ReportListResponse(val data: List) { + @Serializable + data class ReportInfo(val id: String) +} + +@Serializable +data class ReportInstanceListResponse(val data: List) { + @Serializable + data class InstanceInfo(val id: String) +} + +@Serializable +data class ReportSegmentListResponse(val data: List) { + @Serializable + data class SegmentInfo(val attributes: SegmentAttributes) + @Serializable + data class SegmentAttributes(val url: String?, val checksum: String?) +} + +@Serializable +data class AppAnalyticsRequestListResponse(val data: List) + +@Serializable +data class AppAnalyticsRequestData(val id: String) + +@Serializable +data class SingleAnalyticsReportRequestResponse(val data: SingleAnalyticsReportRequestData) + +@Serializable +data class SingleAnalyticsReportRequestData(val attributes: SingleAnalyticsReportRequestAttributes) + +@Serializable +data class SingleAnalyticsReportRequestAttributes(val requestState: String) + +data class DailyDownloadRecord( + val date: LocalDate, + val downloads: Int, + val redownloads: Int, + val updates: Int +) + +data class AnalyticsReportSet( + val daily: List?, + val weekly: List?, + val monthly: List? +) +// #endregion // --- 유틸리티 함수 --- fun loadPrivateKey(path: String): PrivateKey { @@ -72,6 +153,7 @@ fun loadPrivateKey(path: String): PrivateKey { 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() } + fun parseSalesReport(tsvData: String): List { val records = mutableListOf() val lines = tsvData.split("\n").drop(1) @@ -79,12 +161,12 @@ fun parseSalesReport(tsvData: String): List { 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" } + countryCode = columns.getOrElse(12) { "N/A" }, + productTypeIdentifier = columns.getOrElse(6) { "N/A" } )) } catch (e: Exception) { println("TSV 파싱 오류: $line, ${e.message}") @@ -93,15 +175,79 @@ fun parseSalesReport(tsvData: String): List { return records } -// --- 메인 화면 --- +fun parseDownloadsReport(csvData: String): List { + val records = mutableListOf() + val lines = csvData.split("\n").drop(1) + for (line in lines) { + if (line.isBlank()) continue + val columns = line.split(",") + try { + records.add( + DailyDownloadRecord( + date = LocalDate.parse(columns.getOrElse(0) { "" }), + downloads = columns.getOrElse(1) { "0" }.toIntOrNull() ?: 0, + redownloads = columns.getOrElse(2) { "0" }.toIntOrNull() ?: 0, + updates = columns.getOrElse(3) { "0" }.toIntOrNull() ?: 0 + ) + ) + } catch (e: Exception) { + println("CSV 파싱 오류: $line, ${e.message}") + } + } + return records +} + +fun analyzeSalesData(records: List): List { + return records.groupBy { it.title }.map { (title, appRecords) -> + AppSalesSummary( + title = title, + totalUnits = appRecords.sumOf { it.units }, + installs = appRecords.filter { it.productTypeIdentifier == "1" }.sumOf { it.units }, + updates = appRecords.filter { it.productTypeIdentifier == "3" }.sumOf { it.units }, + redownloads = appRecords.filter { it.productTypeIdentifier == "7" || it.productTypeIdentifier == "F" }.sumOf { it.units }, + others = appRecords.filter { it.productTypeIdentifier !in listOf("1", "3", "7", "F") }.sumOf { it.units }, + details = appRecords.sortedByDescending { it.units } + ) + }.sortedByDescending { it.totalUnits } +} + +// --- 메인 애플리케이션 --- @Composable 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(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>(emptyList()) }; var rawTsvReport by remember { mutableStateOf("") }; var statusMessage by remember { mutableStateOf("리포트 조회를 위한 정보를 입력하세요.") }; var totalUnitsBySku by remember { mutableStateOf(null) } + 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(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 statusMessage by remember { mutableStateOf("리포트 조회를 위한 정보를 입력하세요.") } + + // State for Sales Report + var salesAnalysisResult by remember { mutableStateOf>(emptyList()) } + var rawTsvReport by remember { mutableStateOf("") } + + // State for Analytics Report + var analyticsReportSet by remember { mutableStateOf(null) } + var analyticsRequestId by remember { mutableStateOf(null) } - // ⬇️ 변경점 1: 로그 기록 방식을 위에서 아래로 쌓이도록 변경합니다. val client = remember { HttpClient(CIO) { install(Logging) { @@ -109,8 +255,7 @@ fun App(onSaveTsvRequest: (name: String, tsvData: String) -> Unit) { logger = object : Logger { override fun log(message: String) { val timestamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) - // 기존 로그에 새로운 로그를 덧붙입니다. - logText += "[$timestamp]\n$message\n\n" + logText = "[$timestamp]\n$message\n\n" + logText } } } @@ -118,188 +263,411 @@ fun App(onSaveTsvRequest: (name: String, tsvData: String) -> Unit) { json(Json { ignoreUnknownKeys = true isLenient = true + prettyPrint = true + encodeDefaults = true }) } } } DisposableEffect(Unit) { onDispose { client.close() } } + suspend fun fetchReportSegments(reportId: String, granularity: String, date: LocalDate, token: String): List? { + return try { + val dateString = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + val instancesResponse: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/analyticsReports/$reportId/instances") { + header(HttpHeaders.Authorization, "Bearer $token") + parameter("filter[granularity]", granularity) + parameter("filter[processingDate]", dateString) + } + if (!instancesResponse.status.isSuccess()) return null + + val instanceId = instancesResponse.body().data.firstOrNull()?.id ?: return emptyList() + + val segmentsResponse: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/analyticsReportInstances/$instanceId/segments") { + header(HttpHeaders.Authorization, "Bearer $token") + } + if (!segmentsResponse.status.isSuccess()) return null + + val segmentUrls = segmentsResponse.body().data.mapNotNull { it.attributes.url } + if (segmentUrls.isEmpty()) return emptyList() + + coroutineScope { + segmentUrls.map { url -> + async { + val downloadResponse: HttpResponse = client.get(url) { header(HttpHeaders.Authorization, "Bearer $token") } + if (downloadResponse.status.isSuccess()) { + val gzippedBody: ByteArray = downloadResponse.body() + val csvData = GZIPInputStream(ByteArrayInputStream(gzippedBody)).bufferedReader(Charsets.UTF_8).use { it.readText() } + parseDownloadsReport(csvData) + } else { + emptyList() + } + } + }.awaitAll().flatten() + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + 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 = { + modifier = Modifier.width(350.dp), + isAnalyticsRequestInProgress = analyticsRequestId != null, + 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, + onGenerateSalesReport = { (vendorNumber, freq, period) -> scope.launch { - // ⬇️ 변경점 2: API 요청 시작 시, 기존 로그를 모두 지웁니다. logText = "" - - val key = privateKey - if (key == null) { statusMessage = "오류: 개인키를 선택하세요"; return@launch } + val key = privateKey ?: run { statusMessage = "오류: 개인키를 선택하세요"; return@launch } try { val token = createJWT(key, issuerId, keyId) - when (selectedTab) { - ReportType.SALES -> { - val (vendorNumber, freq, period) = it as Triple - 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 - 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] 조회 완료" + salesAnalysisResult = emptyList(); analyticsReportSet = null + if (vendorNumber.isBlank()) { statusMessage = "오류: Vendor Number를 입력하세요."; return@launch } + statusMessage = "[$period] 전체 판매 리포트 로딩 중..." + 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) + salesAnalysisResult = analyzeSalesData(parsedRecords) + statusMessage = "[$period] 리포트 조회 완료" } catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } } - } + }, + onRequestAnalyticsReport = { adamId -> + scope.launch { + logText = ""; analyticsReportSet = null + val key = privateKey ?: run { statusMessage = "오류: 개인키를 선택하세요"; return@launch } + if (adamId.isBlank()) { statusMessage = "오류: App Adam ID를 입력하세요."; return@launch } + + try { + val token = createJWT(key, issuerId, keyId) + statusMessage = "분석 리포트 생성 요청 중..." + val requestBody = AnalyticsReportRequest(data = AnalyticsReportRequest.Data(attributes = AnalyticsReportRequest.Attributes(), relationships = AnalyticsReportRequest.Relationships(app = AnalyticsReportRequest.App(data = AnalyticsReportRequest.AppData(id = adamId))))) + val response: HttpResponse = client.post("https://api.appstoreconnect.apple.com/v1/analyticsReportRequests") { + header(HttpHeaders.Authorization, "Bearer $token") + contentType(ContentType.Application.Json) + setBody(requestBody) + } + + if (response.status.isSuccess()) { + analyticsRequestId = response.body().data.id + statusMessage = "리포트 생성 요청 완료. 잠시 후 '요청 상태 확인' 또는 '생성된 데이터 확인'을 눌러주세요." + } else if (response.status.value == 409) { + statusMessage = "이미 진행 중인 요청이 있습니다. 기존 요청을 조회합니다..." + try { + val listResponse: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/apps/$adamId/analyticsReportRequests") { + header(HttpHeaders.Authorization, "Bearer $token") +// parameter("sort", "-createdDate") + } + if (listResponse.status.isSuccess()) { + val latestRequest = listResponse.body().data.firstOrNull() + if (latestRequest != null) { + analyticsRequestId = latestRequest.id + statusMessage = "진행 중인 리포트 요청을 찾았습니다. '요청 상태 확인' 또는 '생성된 데이터 확인'을 눌러주세요." + } else { + statusMessage = "오류: 기존 리포트 요청을 찾을 수 없습니다." + } + } else { + throw Exception("기존 리포트 요청 목록 조회 실패 (${listResponse.status}): ${listResponse.bodyAsText()}") + } + } catch (e: Exception) { + e.printStackTrace(); statusMessage = "오류: 기존 요청을 조회하는 중 문제가 발생했습니다: ${e.message}" + } + } else { + throw Exception("API 오류 (${response.status}): ${response.bodyAsText()}") + } + } catch(e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } + } + }, + onCheckRequestStatus = { + scope.launch { + logText = "" + val key = privateKey ?: run { statusMessage = "오류: 개인키를 선택하세요"; return@launch } + val reqId = analyticsRequestId ?: run { statusMessage = "오류: 먼저 데이터 생성을 요청하세요."; return@launch } + + try { + val token = createJWT(key, issuerId, keyId) + statusMessage = "리포트 요청 상태 확인 중... (ID: $reqId)" + val response: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/analyticsReportRequests/$reqId") { + header(HttpHeaders.Authorization, "Bearer $token") + } + if (response.status.isSuccess()) { + val status = response.body().data.attributes.requestState + statusMessage = "현재 상태: $status" + } else { + throw Exception("상태 확인 실패 (${response.status}): ${response.bodyAsText()}") + } + } catch(e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } + } + }, + onDownloadAnalyticsReport = { selectedDate -> + scope.launch { + logText = "" + val key = privateKey ?: run { statusMessage = "오류: 개인키를 선택하세요"; return@launch } + val reqId = analyticsRequestId ?: run { statusMessage = "오류: 먼저 데이터 생성을 요청하세요."; return@launch } + + try { + val token = createJWT(key, issuerId, keyId) + statusMessage = "리포트 목록 조회 중... (ID: $reqId)" + val reportsResponse: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/analyticsReportRequests/$reqId/reports") { + header(HttpHeaders.Authorization, "Bearer $token") + parameter("filter[category]", "APP_USAGE") + } + if (!reportsResponse.status.isSuccess()) throw Exception("리포트 목록 조회 오류 (${reportsResponse.status}): ${reportsResponse.bodyAsText()}") + + val reportId = reportsResponse.body().data.firstOrNull()?.id + if (reportId == null) { + statusMessage = "다운로드 리포트가 아직 준비되지 않았습니다. 잠시 후 다시 시도하세요." + return@launch + } + + statusMessage = "일간/주간/월간 데이터 병렬 다운로드 중..." + val (dailyResult, weeklyResult, monthlyResult) = coroutineScope { + val daily = async { fetchReportSegments(reportId, "DAILY", selectedDate, token) } + val weekly = async { fetchReportSegments(reportId, "WEEKLY", selectedDate, token) } + val monthly = async { fetchReportSegments(reportId, "MONTHLY", selectedDate, token) } + Triple(daily.await(), weekly.await(), monthly.await()) + } + + analyticsReportSet = AnalyticsReportSet( + daily = dailyResult?.sortedBy { it.date }, + weekly = weeklyResult?.sortedBy { it.date }, + monthly = monthlyResult?.sortedBy { it.date } + ) + + statusMessage = "모든 리포트 조회 완료" + analyticsRequestId = null + } catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } + } + }, + isAnalyticsDownloadReady = analyticsRequestId != null ) Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) - ResultsPanel(modifier = Modifier.weight(1f), selectedTab = selectedTab, statusMessage = statusMessage, salesResult = salesResult, totalUnitsBySku = totalUnitsBySku, onSaveRequest = { onSaveTsvRequest(it, rawTsvReport) }) + ResultsPanel(modifier = Modifier.weight(1f), selectedTab = selectedTab, statusMessage = statusMessage, salesAnalysisResult = salesAnalysisResult, analyticsReportSet = analyticsReportSet, 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) { +fun SettingsPanel( + modifier: Modifier = Modifier, + isAnalyticsRequestInProgress: Boolean = false, + prefs: Preferences, + selectedTab: ReportType, + onTabSelected: (ReportType) -> Unit, + filePath: String, + onFilePathChange: (String) -> Unit, + issuerId: String, + onIssuerIdChange: (String) -> Unit, + keyId: String, + onKeyIdChange: (String) -> Unit, + error: String?, + onGenerateSalesReport: (Triple) -> Unit, + onRequestAnalyticsReport: (String) -> Unit, + onCheckRequestStatus: () -> Unit, + onDownloadAnalyticsReport: (LocalDate) -> Unit, + isAnalyticsDownloadReady: Boolean +) { 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) 파일") + 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("찾기") } + TextField(value = filePath, onValueChange = {}, 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)) + 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_ANALYTICS, onClick = { onTabSelected(ReportType.APP_ANALYTICS) }, text = { Text("앱 분석") }) + } + Spacer(Modifier.height(16.dp)) + when (selectedTab) { - ReportType.SALES -> SalesReportSettings(prefs, onGenerateRequest) - ReportType.APP_SPECIFIC_SALES -> AppSpecificSalesSettings(prefs, onGenerateRequest) + ReportType.SALES -> SalesReportSettings(prefs, onGenerateSalesReport) + ReportType.APP_ANALYTICS -> AppAnalyticsSettings(prefs, onRequestAnalyticsReport, onCheckRequestStatus, onDownloadAnalyticsReport, isAnalyticsDownloadReady, isAnalyticsRequestInProgress) } } } @Composable fun SalesReportSettings(prefs: Preferences, onGenerateRequest: (Triple) -> 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) } - 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)) } } - 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("전체 판매 리포트 조회") } + 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(2).format(DateTimeFormatter.ISO_LOCAL_DATE)) } + 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("리포트 빈도") + 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)) + } + } + 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(2)}, + 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("전체 판매 리포트 조회") + } } @Composable -fun AppSpecificSalesSettings(prefs: Preferences, onGenerateRequest: (Triple) -> 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) 조회") } +fun AppAnalyticsSettings( + prefs: Preferences, + onRequest: (String) -> Unit, + onCheckStatus: () -> Unit, + onDownload: (LocalDate) -> Unit, + isDownloadReady: Boolean, + isRequestInProgress: Boolean +) { + var adamId by remember { mutableStateOf(prefs.get(APP_ADAM_ID_KEY, "")) } + var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(2)) } + var showDatePicker by remember { mutableStateOf(false) } + + Text("App Adam ID") + TextField(value = adamId, onValueChange = {adamId = it; prefs.put(APP_ADAM_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()) + Text("App Store Connect의 '앱 정보'에서 확인 가능", style = MaterialTheme.typography.caption) + 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)) + ) + } + if (showDatePicker) { + SimpleDatePicker( + initialDate = reportDate, + onDateSelected = { reportDate = it; showDatePicker = false }, + onDismissRequest = { showDatePicker = false } + ) + } + Text("2일 이전 날짜 권장", style = MaterialTheme.typography.caption) + Spacer(Modifier.height(16.dp)) + + Button(onClick = { onRequest(adamId) }, modifier = Modifier.fillMaxWidth(), enabled = !isRequestInProgress) { + Text("1. 데이터 생성 요청") + } + Spacer(Modifier.height(8.dp)) + Button(onClick = { onCheckStatus() }, modifier = Modifier.fillMaxWidth(), enabled = isDownloadReady) { + Text("요청 상태 확인") + } + Spacer(Modifier.height(8.dp)) + Button(onClick = { onDownload(reportDate) }, modifier = Modifier.fillMaxWidth(), enabled = isDownloadReady) { + Text("2. 생성된 데이터 확인") + } } @Composable -fun ResultsPanel(modifier: Modifier = Modifier, selectedTab: ReportType, statusMessage: String, salesResult: List, totalUnitsBySku: Long?, onSaveRequest: (String) -> Unit) { +fun ResultsPanel( + modifier: Modifier = Modifier, + selectedTab: ReportType, + statusMessage: String, + salesAnalysisResult: List, + analyticsReportSet: AnalyticsReportSet?, + 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()}") } + ReportType.SALES -> SalesResultView(statusMessage, salesAnalysisResult) { onSaveRequest("sales-report-${System.currentTimeMillis()}") } + ReportType.APP_ANALYTICS -> AppDownloadsResultView(statusMessage, analyticsReportSet) } } } -// Main.kt - @Composable -fun SalesResultView(statusMessage: String, result: List, onSaveRequest: () -> Unit) { +fun SalesResultView(statusMessage: String, result: List, 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 totalUnits = result.sumOf { it.totalUnits } + val totalInstalls = result.sumOf { it.installs } + val totalUpdates = result.sumOf { it.updates } + val totalRedownloads = result.sumOf { it.redownloads } + val totalOthers = result.sumOf { it.others } - // ⬇️ 데이터를 앱별로 그룹화하고 다운로드 수에 따라 정렬하는 로직 추가 - val sortedAndGroupedApps = result - .groupBy { it.title } // 1. 앱 이름으로 그룹화 - .mapValues { (_, records) -> - records.sortedByDescending { it.units } // 2. 각 그룹 내에서 국가를 다운로드 수로 정렬 - } - .toList() - .sortedByDescending { (_, records) -> records.sumOf { it.units } } // 3. 앱 자체를 총 다운로드 수로 정렬 + Text("전체 합계", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold) + Text("총 다운로드: ${NumberFormat.getInstance().format(totalUnits)}") + Text(" - 신규 설치: ${NumberFormat.getInstance().format(totalInstalls)}") + Text(" - 재설치: ${NumberFormat.getInstance().format(totalRedownloads)}") + Text(" - 업데이트: ${NumberFormat.getInstance().format(totalUpdates)}") + if(totalOthers > 0) Text(" - 기타: ${NumberFormat.getInstance().format(totalOthers)}") + Spacer(Modifier.height(12.dp)); Divider() - 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)) - } - } - - // --- 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 - ) - } - Divider(color = Color.Gray.copy(alpha = 0.2f), modifier = Modifier.padding(horizontal = 8.dp)) - } - - // --- 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) // 앱과 앱 사이를 구분하는 더 두꺼운 구분선 - } - } + LazyColumn { + result.forEach { summary -> item { AppSummaryCard(summary); Spacer(Modifier.height(8.dp)) } } } Spacer(Modifier.height(16.dp)) Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth(), enabled = result.isNotEmpty()) { @@ -309,34 +677,221 @@ fun SalesResultView(statusMessage: String, result: List, onSa } @Composable -fun AppSpecificResultView(statusMessage: String, totalUnits: Long?, onSaveRequest: () -> Unit) { +fun AppSummaryCard(summary: AppSalesSummary) { + var expanded by remember { mutableStateOf(false) } + Card(elevation = 2.dp, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.clickable { expanded = !expanded }.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(summary.title, style = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold), modifier = Modifier.weight(1f)) + Text(NumberFormat.getInstance().format(summary.totalUnits), style = MaterialTheme.typography.h6) + } + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { + InfoChip("설치", summary.installs, Color(0xFF4CAF50)) + InfoChip("재설치", summary.redownloads, Color(0xFF2196F3)) + InfoChip("업데이트", summary.updates, Color(0xFF9C27B0)) + if (summary.others > 0) { InfoChip("기타", summary.others, Color.Gray) } + } + if (expanded) { + Spacer(Modifier.height(12.dp)); Divider(); Spacer(Modifier.height(8.dp)) + Text("국가별 상세", fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 4.dp)) + summary.details.forEach { record -> + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp)) { + Text(record.countryCode, modifier = Modifier.weight(1f)) + Text(NumberFormat.getInstance().format(record.units), textAlign = TextAlign.End) + } + } + } + } + } +} + +@Composable +fun InfoChip(label: String, value: Int, color: Color) { + if (value == 0) return + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color)) + Spacer(Modifier.width(4.dp)) + Text("$label: ${NumberFormat.getInstance().format(value)}", fontSize = 13.sp) + } +} + +@Composable +fun AppDownloadsResultView(statusMessage: String, reportSet: AnalyticsReportSet?) { 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)") } + val scrollState = rememberScrollState() + + if (reportSet != null) { + Column(Modifier.verticalScroll(scrollState)) { + ReportSection("일간 리포트 (Daily)", reportSet.daily) + Spacer(Modifier.height(16.dp)) + ReportSection("주간 리포트 (Weekly)", reportSet.weekly) + Spacer(Modifier.height(16.dp)) + ReportSection("월간 리포트 (Monthly)", reportSet.monthly) + } + } +} + +@Composable +private fun ReportSection(title: String, data: List?) { + Text(title, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + + when { + data == null -> { + Text("오류가 발생하여 데이터를 가져오지 못했습니다.", color = MaterialTheme.colors.error) + } + data.isEmpty() -> { + Text("해당 기간에 집계된 데이터가 없습니다.") + } + else -> { + val totalDownloads = data.sumOf { it.downloads } + val totalRedownloads = data.sumOf { it.redownloads } + val totalUpdates = data.sumOf { it.updates } + + Card(elevation = 4.dp, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + Column(Modifier.padding(16.dp)) { + Text("기간 합계", style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + AnalyticsSummaryRow("신규 다운로드", totalDownloads.toLong()) + AnalyticsSummaryRow("재 다운로드", totalRedownloads.toLong()) + AnalyticsSummaryRow("업데이트", totalUpdates.toLong()) + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colors.onSurface.copy(alpha = 0.1f)).padding(horizontal = 8.dp, vertical = 8.dp), + ) { + Text("날짜", modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold) + Text("신규", modifier = Modifier.weight(1f), textAlign = TextAlign.End, fontWeight = FontWeight.Bold) + Text("재설치", modifier = Modifier.weight(1f), textAlign = TextAlign.End, fontWeight = FontWeight.Bold) + Text("업데이트", modifier = Modifier.weight(1f), textAlign = TextAlign.End, fontWeight = FontWeight.Bold) + } + Divider() + data.forEach { record -> + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 10.dp)) { + Text(record.date.format(DateTimeFormatter.ISO_LOCAL_DATE), modifier = Modifier.weight(1.5f)) + Text(NumberFormat.getInstance().format(record.downloads), modifier = Modifier.weight(1f), textAlign = TextAlign.End) + Text(NumberFormat.getInstance().format(record.redownloads), modifier = Modifier.weight(1f), textAlign = TextAlign.End) + Text(NumberFormat.getInstance().format(record.updates), modifier = Modifier.weight(1f), textAlign = TextAlign.End) + } + Divider(color = Color.Gray.copy(alpha = 0.3f)) + } + } + } + } +} + +@Composable +private fun AnalyticsSummaryRow(label: String, value: Long) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(label, style = MaterialTheme.typography.body2, modifier = Modifier.weight(1f)) + Text(NumberFormat.getInstance().format(value), style = MaterialTheme.typography.body2, fontWeight = FontWeight.SemiBold) } } @Composable 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) } } + 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)) { + SelectionContainer { + Text( + text = logText, + modifier = Modifier.fillMaxSize().verticalScroll(scrollState), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } } @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 - 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) } } } - } } } + 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 + + // Using a simple grid layout with Rows and Columns as LazyVerticalGrid is not stable in all environments. + Column { + val totalCells = firstDayOfMonth + daysInMonth + val rows = (totalCells + 6) / 7 + for (row in 0 until rows) { + Row { + for (col in 0 until 7) { + val dayIndex = row * 7 + col + if (dayIndex < firstDayOfMonth || dayIndex >= totalCells) { + Box(Modifier.size(40.dp)) + } else { + val day = dayIndex - firstDayOfMonth + 1 + val date = displayedYearMonth.atDay(day) + 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", + color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface + ) + } + } + } + } + } + } + } + } + } } fun main() = application { - val snackbarHostState = remember { SnackbarHostState() }; val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() Window(onCloseRequest = ::exitApplication, title = "App Store Connect 리포트") { MaterialTheme { Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues -> Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - 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}") } } } } ) + 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}") } + } + } + }) } } }