2025-09-12 16:31:23 +09:00

899 lines
44 KiB
Kotlin

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.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
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
import androidx.compose.ui.text.font.FontFamily
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.*
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
import java.io.ByteArrayInputStream
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
import java.time.*
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.prefs.Preferences
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 VENDOR_NUMBER_KEY = "APP_STORE_VENDOR_NUMBER"
private const val APP_ADAM_ID_KEY = "APP_STORE_ADAM_ID"
enum class ReportType { SALES, APP_ANALYTICS }
// --- 데이터 클래스 정의 ---
// #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<SalesReportRecord>)
// #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<ReportInfo>) {
@Serializable
data class ReportInfo(val id: String)
}
@Serializable
data class ReportInstanceListResponse(val data: List<InstanceInfo>) {
@Serializable
data class InstanceInfo(val id: String)
}
@Serializable
data class ReportSegmentListResponse(val data: List<SegmentInfo>) {
@Serializable
data class SegmentInfo(val attributes: SegmentAttributes)
@Serializable
data class SegmentAttributes(val url: String?, val checksum: String?)
}
@Serializable
data class AppAnalyticsRequestListResponse(val data: List<AppAnalyticsRequestData>)
@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<DailyDownloadRecord>?,
val weekly: List<DailyDownloadRecord>?,
val monthly: List<DailyDownloadRecord>?
)
// #endregion
// --- 유틸리티 함수 ---
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)
}
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<SalesReportRecord> {
val records = mutableListOf<SalesReportRecord>()
val lines = tsvData.split("\n").drop(1)
lines.forEach { line ->
if (line.isBlank()) return@forEach
val columns = line.split("\t")
try {
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" },
productTypeIdentifier = columns.getOrElse(6) { "N/A" }
))
} catch (e: Exception) {
println("TSV 파싱 오류: $line, ${e.message}")
}
}
return records
}
fun parseDownloadsReport(csvData: String): List<DailyDownloadRecord> {
val records = mutableListOf<DailyDownloadRecord>()
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<SalesReportRecord>): List<AppSalesSummary> {
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<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 statusMessage by remember { mutableStateOf("리포트 조회를 위한 정보를 입력하세요.") }
// State for Sales Report
var salesAnalysisResult by remember { mutableStateOf<List<AppSalesSummary>>(emptyList()) }
var rawTsvReport by remember { mutableStateOf("") }
// State for Analytics Report
var analyticsReportSet by remember { mutableStateOf<AnalyticsReportSet?>(null) }
var analyticsRequestId by remember { mutableStateOf<String?>(null) }
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" + logText
}
}
}
install(ContentNegotiation) {
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<DailyDownloadRecord>? {
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<ReportInstanceListResponse>().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<ReportSegmentListResponse>().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),
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 {
logText = ""
val key = privateKey ?: run { statusMessage = "오류: 개인키를 선택하세요"; return@launch }
try {
val token = createJWT(key, issuerId, keyId)
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<AnalyticsReportRequestResponse>().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<AppAnalyticsRequestListResponse>().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<SingleAnalyticsReportRequestResponse>().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<ReportListResponse>().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, 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,
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<String, String, String>) -> 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) 파일")
Row(verticalAlignment = Alignment.CenterVertically) {
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_ANALYTICS, onClick = { onTabSelected(ReportType.APP_ANALYTICS) }, text = { Text("앱 분석") })
}
Spacer(Modifier.height(16.dp))
when (selectedTab) {
ReportType.SALES -> SalesReportSettings(prefs, onGenerateSalesReport)
ReportType.APP_ANALYTICS -> AppAnalyticsSettings(prefs, onRequestAnalyticsReport, onCheckRequestStatus, onDownloadAnalyticsReport, isAnalyticsDownloadReady, isAnalyticsRequestInProgress)
}
}
}
@Composable
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(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 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,
salesAnalysisResult: List<AppSalesSummary>,
analyticsReportSet: AnalyticsReportSet?,
onSaveRequest: (String) -> Unit
) {
Column(modifier = modifier.fillMaxHeight().padding(16.dp)) {
when(selectedTab) {
ReportType.SALES -> SalesResultView(statusMessage, salesAnalysisResult) { onSaveRequest("sales-report-${System.currentTimeMillis()}") }
ReportType.APP_ANALYTICS -> AppDownloadsResultView(statusMessage, analyticsReportSet)
}
}
}
@Composable
fun SalesResultView(statusMessage: String, result: List<AppSalesSummary>, onSaveRequest: () -> Unit) {
Text(statusMessage, style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(bottom = 8.dp))
if (result.isNotEmpty()) {
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 }
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()
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()) {
Text("원본 데이터 다운로드 (TSV)")
}
}
}
@Composable
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))
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<DailyDownloadRecord>?) {
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)) {
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
// 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()
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}") }
}
}
})
}
}
}
}
}