2025-09-11 15:49:10 +09:00
// Main.kt
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.grid.GridCells
2025-09-11 15:49:10 +09:00
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
2025-09-11 16:32:24 +09:00
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
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.*
import io.ktor.http.ContentType
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.contentType
import io.ktor.http.isSuccess
2025-09-11 15:49:10 +09:00
import io.ktor.serialization.kotlinx.json.*
2025-09-11 16:32:24 +09:00
import kotlinx.coroutines.delay
2025-09-11 15:49:10 +09:00
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
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 16:32:24 +09:00
private const val APP _ID _KEY = " APP_STORE_APP_ID "
private const val VENDOR _NUMBER _KEY = " APP_STORE_VENDOR_NUMBER "
enum class ReportType { SALES , ANALYTICS }
// --- 데이터 클래스 정의 ---
data class SalesReportRecord ( val title : String , val units : Int )
data class AnalyticsResult ( val installations : Long = 0 , val redownloads : Long = 0 , val updates : Long = 0 )
@Serializable data class AnalyticsReportRequestResponse ( val data : AnalyticsReportRequestData )
@Serializable data class AnalyticsReportRequestData ( val id : String , val links : Links )
@Serializable data class Links ( val self : String )
@Serializable data class AnalyticsReportResponse ( val data : List < AnalyticsReportData > = emptyList ( ) )
@Serializable data class AnalyticsReportData ( val attributes : ReportAttributes ? = null )
@Serializable data class ReportAttributes ( val downloadUrl : String ? = null )
// --- 유틸리티 함수 ---
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 , scope : List < String > ? ) : String {
val now = Instant . now ( ) ; val expire = now . plusSeconds ( 20 * 60 ) ; val builder = Jwts . builder ( ) . setHeaderParam ( " alg " , " ES256 " ) . setHeaderParam ( " kid " , keyId ) . setHeaderParam ( " typ " , " JWT " ) . setIssuer ( issuerId ) . setAudience ( " appstoreconnect-v1 " ) . setIssuedAt ( Date . from ( now ) ) . setExpiration ( Date . from ( expire ) ) ; if ( ! scope . isNullOrEmpty ( ) ) { builder . claim ( " scope " , scope ) } ; return builder . 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 " } , units = columns . getOrElse ( 7 ) { " 0 " } . toIntOrNull ( ) ?: 0 ) ) } catch ( e : Exception ) { println ( " TSV 파싱 오류: $line , ${e.message} " ) } }
return records . groupBy { it . title } . map { ( title , unitList ) -> SalesReportRecord ( title , unitList . sumOf { it . units } ) } . sortedByDescending { it . units }
}
fun parseAnalyticsReport ( csvData : String ) : AnalyticsResult {
var installs = 0L ; var redownloads = 0L ; var updates = 0L
val lines = csvData . split ( " \n " ) . drop ( 1 )
lines . forEach { line -> if ( line . isBlank ( ) ) return @forEach ; val columns = line . split ( " , " ) ; try { when ( columns . getOrNull ( 0 ) ?. trim ( ) ) { " INSTALLATIONS " -> installs = columns . getOrNull ( 1 ) ?. toLongOrNull ( ) ?: 0L ; " REDOWNLOADS " -> redownloads = columns . getOrNull ( 1 ) ?. toLongOrNull ( ) ?: 0L ; " UPDATES " -> updates = columns . getOrNull ( 1 ) ?. toLongOrNull ( ) ?: 0L } } catch ( e : Exception ) { println ( " Analytics CSV 파싱 오류: $line , ${e.message} " ) } }
return AnalyticsResult ( installations = installs , redownloads = redownloads , updates = updates )
2025-09-11 15:49:10 +09:00
}
2025-09-11 16:32:24 +09:00
// --- 메인 화면 및 컴포저블 ---
2025-09-11 15:49:10 +09:00
@Composable
2025-09-11 16:32:24 +09:00
fun App ( onSaveTsvRequest : ( period : String , tsvData : String ) -> Unit , onSaveCsvRequest : ( appId : String , date : LocalDate , result : AnalyticsResult ) -> Unit ) {
val prefs = remember { Preferences . userRoot ( ) . node ( " com.example.combinedreportgenerator " ) } ; 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 } } } ; val client = remember { HttpClient ( CIO ) { install ( Logging ) { logger = object : Logger { override fun log ( message : String ) { println ( " KTOR LOG: $message " ) } } ; level = LogLevel . ALL } ; install ( ContentNegotiation ) { json ( Json { ignoreUnknownKeys = true ; isLenient = true ; } ) } } } ; DisposableEffect ( Unit ) { onDispose { client . close ( ) } }
Row ( modifier = Modifier . fillMaxSize ( ) ) {
Column ( modifier = Modifier . width ( 350. dp ) . 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 = { issuerId = it ; prefs . put ( ISSUER _ID _KEY , it ) } , singleLine = true , modifier = Modifier . fillMaxWidth ( ) ) ; Spacer ( Modifier . height ( 8. dp ) ) ; Text ( " Key ID " ) ; TextField ( value = keyId , onValueChange = { keyId = it ; prefs . put ( KEY _ID _KEY , it ) } , singleLine = true , modifier = Modifier . fillMaxWidth ( ) ) ; Spacer ( Modifier . height ( 8. dp ) ) ; Text ( " 개인키(.p8) 파일 " )
Row ( verticalAlignment = Alignment . CenterVertically ) {
TextField ( value = filePath , onValueChange = { filePath = it } , 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 ) { filePath = 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 = { selectedTab = ReportType . SALES } , text = { Text ( " 판매 리포트 " ) } ) ; Tab ( selected = selectedTab == ReportType . ANALYTICS , onClick = { selectedTab = ReportType . ANALYTICS } , text = { Text ( " 앱 분석 " ) } ) } ; Spacer ( Modifier . height ( 16. dp ) )
when ( selectedTab ) {
ReportType . SALES -> SalesReportSettings ( prefs , privateKey , client , issuerId , keyId , onSaveTsvRequest )
ReportType . ANALYTICS -> AnalyticsReportSettings ( prefs , privateKey , client , issuerId , keyId , onSaveCsvRequest )
2025-09-11 15:49:10 +09:00
}
}
2025-09-11 16:32:24 +09:00
Divider ( modifier = Modifier . fillMaxHeight ( ) . width ( 1. dp ) )
2025-09-11 15:49:10 +09:00
}
}
2025-09-11 16:32:24 +09:00
// --- 판매 리포트 관련 컴포저블 ---
2025-09-11 15:49:10 +09:00
@Composable
2025-09-11 16:32:24 +09:00
fun SalesReportSettings ( prefs : Preferences , privateKey : PrivateKey ? , client : HttpClient , issuerId : String , keyId : String , onSaveTsvRequest : ( period : String , tsvData : String ) -> Unit ) {
var vendorNumber by remember { mutableStateOf ( prefs . get ( VENDOR _NUMBER _KEY , " " ) ) } ; var statusMessage by remember { mutableStateOf ( " 리포트 조회를 위한 정보를 입력하세요. " ) } ; var salesResult by remember { mutableStateOf < List < SalesReportRecord > > ( emptyList ( ) ) } ; var rawTsvReport by remember { mutableStateOf ( " " ) } ; val scope = rememberCoroutineScope ( ) ; 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 ) ) }
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 ( " 조회 기간 " ) ; OutlinedTextField ( value = reportPeriod , onValueChange = { reportPeriod = it } , modifier = Modifier . fillMaxWidth ( ) ) ; Text ( " DAILY/WEEKLY: YYYY-MM-DD, MONTHLY: YYYY-MM, YEARLY: YYYY " , style = MaterialTheme . typography . caption ) ; Spacer ( Modifier . height ( 16. dp ) )
Button ( onClick = {
scope . launch {
if ( privateKey == null || vendorNumber . isBlank ( ) || issuerId . isBlank ( ) || keyId . isBlank ( ) ) { statusMessage = " 오류: 모든 정보를 입력해주세요. " ; return @launch }
try {
statusMessage = " [ $reportPeriod ] 판매 리포트 로딩 중... " ; salesResult = emptyList ( )
val token = createJWT ( privateKey , issuerId , keyId , 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] " , selectedFrequency )
when ( selectedFrequency ) { " DAILY " , " WEEKLY " -> parameter ( " filter[reportDate] " , reportPeriod ) ; " MONTHLY " -> parameter ( " filter[reportMonth] " , reportPeriod ) ; " YEARLY " -> parameter ( " filter[reportYear] " , reportPeriod ) }
2025-09-11 15:49:10 +09:00
}
2025-09-11 16:32:24 +09:00
if ( ! response . status . isSuccess ( ) ) throw Exception ( " API 오류 ( ${response.status} ): ${response.bodyAsText()} " )
rawTsvReport = GZIPInputStream ( ByteArrayInputStream ( response . body ( ) ) ) . bufferedReader ( Charsets . UTF _8 ) . use { it . readText ( ) }
salesResult = parseSalesReport ( rawTsvReport ) ; statusMessage = " [ $reportPeriod ] 리포트 조회 완료 "
} catch ( e : Exception ) { e . printStackTrace ( ) ; statusMessage = " 오류: ${e.message} " }
2025-09-11 15:49:10 +09:00
}
2025-09-11 16:32:24 +09:00
} , modifier = Modifier . fillMaxWidth ( ) , enabled = privateKey != null ) { Text ( " 판매 리포트 조회 " ) }
Spacer ( Modifier . height ( 16. dp ) ) ; Divider ( ) ; SalesResultView ( statusMessage , salesResult ) { onSaveTsvRequest ( reportPeriod , rawTsvReport ) }
}
2025-09-11 15:49:10 +09:00
2025-09-11 16:32:24 +09:00
@Composable
fun SalesResultView ( statusMessage : String , result : List < SalesReportRecord > , onSaveRequest : ( ) -> Unit ) {
Column ( Modifier . fillMaxSize ( ) . padding ( top = 16. dp ) ) {
Text ( statusMessage , style = MaterialTheme . typography . subtitle2 ) ; Spacer ( Modifier . height ( 8. dp ) )
if ( result . isNotEmpty ( ) ) {
Text ( " 총 다운로드 (Units): ${NumberFormat.getInstance().format(result.sumOf { it.units } )} " , fontWeight = FontWeight . Bold ) ; Spacer ( Modifier . height ( 8. dp ) ) ; Row ( Modifier . fillMaxWidth ( ) . padding ( vertical = 4. dp ) ) { Text ( " 앱 이름 " , Modifier . weight ( 1f ) , fontWeight = FontWeight . Bold ) ; Text ( " 다운로드 수 " , Modifier . width ( 100. dp ) , fontWeight = FontWeight . Bold , textAlign = TextAlign . End ) } ; Divider ( )
LazyColumn ( modifier = Modifier . weight ( 1f ) ) { items ( result ) { ( title , units ) -> Row ( Modifier . fillMaxWidth ( ) . padding ( vertical = 8. dp ) , verticalAlignment = Alignment . CenterVertically ) { Text ( title , Modifier . weight ( 1f ) ) ; Text ( NumberFormat . getInstance ( ) . format ( units ) , Modifier . width ( 100. dp ) , textAlign = TextAlign . End ) } ; Divider ( color = Color . Gray . copy ( alpha = 0.2f ) ) } }
Spacer ( Modifier . height ( 16. dp ) ) ; Button ( onClick = onSaveRequest , modifier = Modifier . fillMaxWidth ( ) ) { Text ( " 원본 TSV 다운로드 " ) }
}
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 16:32:24 +09:00
// --- 앱 분석 관련 컴포저블 ---
@Composable
fun AnalyticsReportSettings ( prefs : Preferences , privateKey : PrivateKey ? , client : HttpClient , issuerId : String , keyId : String , onSaveCsvRequest : ( appId : String , date : LocalDate , result : AnalyticsResult ) -> Unit ) {
var appId by remember { mutableStateOf ( prefs . get ( APP _ID _KEY , " " ) ) } ; var statusMessage by remember { mutableStateOf ( " 분석할 앱의 정보를 입력하세요. " ) } ; var analyticsResult by remember { mutableStateOf < AnalyticsResult ? > ( null ) } ; var reportDate by remember { mutableStateOf ( LocalDate . now ( ) . minusDays ( 3 ) ) } ; val scope = rememberCoroutineScope ( ) ; var showDatePicker by remember { mutableStateOf ( false ) }
Text ( " App ID " ) ; TextField ( value = appId , onValueChange = { appId = it ; prefs . put ( APP _ID _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 ( " YYYY-MM-DD 형식. 3~4일 이전 날짜 권장 " , style = MaterialTheme . typography . caption ) ; Spacer ( Modifier . height ( 16. dp ) )
Button ( onClick = {
scope . launch {
if ( privateKey == null || appId . isBlank ( ) || issuerId . isBlank ( ) || keyId . isBlank ( ) ) { statusMessage = " 오류: 모든 정보를 입력해주세요. " ; return @launch }
try {
val dateString = reportDate . format ( DateTimeFormatter . ISO _LOCAL _DATE )
statusMessage = " [ $dateString ] 분석 리포트 요청 중... " ; analyticsResult = null
val token = createJWT ( privateKey , issuerId , keyId , null )
// ⬇️ 1. 리포트 생성 요청 (POST) - API 버전을 v1으로 수정
val requestBody = """ {"data":{"type":"analyticsReportRequests","attributes":{"accessType":"ONE_TIME_SNAPSHOT","stopped":true,"granularity":"DAILY","dimensionFilters":[{"dimensionKey":"app","optionKeys":["$appId"]}],"metricFilters":[{"metricType":"INSTALLATIONS"},{"metricType":"REDOWNLOADS"},{"metricType":"UPDATES"}]}}} """
val requestResponse : HttpResponse = client . post ( " https://api.appstoreconnect.apple.com/v1/analyticsReportRequests " ) {
header ( HttpHeaders . Authorization , " Bearer $token " ) ; contentType ( ContentType . Application . Json ) ; setBody ( requestBody )
}
if ( ! requestResponse . status . isSuccess ( ) ) throw Exception ( " 리포트 요청 실패 ( ${requestResponse.status} ): ${requestResponse.bodyAsText()} " )
val reportRequestData : AnalyticsReportRequestResponse = requestResponse . body ( )
// ⬇️ 2. 리포트 생성 대기 및 확인
var downloadUrl : String ? = null
repeat ( 10 ) {
statusMessage = " [ $dateString ] 리포트 생성 대기 중... ( ${it + 1} /10) " ; delay ( 5000 )
val reportListResponse : HttpResponse = client . get ( reportRequestData . data . links . self + " /reports " ) { header ( HttpHeaders . Authorization , " Bearer $token " ) }
if ( reportListResponse . status . isSuccess ( ) ) {
val reports : AnalyticsReportResponse = reportListResponse . body ( )
downloadUrl = reports . data . firstOrNull ( ) ?. attributes ?. downloadUrl
if ( downloadUrl != null ) { statusMessage = " [ $dateString ] 리포트 다운로드 중... " ; return @repeat }
2025-09-11 15:49:10 +09:00
}
2025-09-11 16:32:24 +09:00
}
if ( downloadUrl == null ) throw Exception ( " 리포트 생성 시간 초과 " )
// ⬇️ 3. 생성된 리포트 다운로드 및 파싱
val reportDataResponse : HttpResponse = client . get ( downloadUrl !! ) { header ( HttpHeaders . Authorization , " Bearer $token " ) }
if ( ! reportDataResponse . status . isSuccess ( ) ) throw Exception ( " 리포트 다운로드 실패: ${reportDataResponse.status} " )
val reportCsv = GZIPInputStream ( ByteArrayInputStream ( reportDataResponse . body ( ) ) ) . bufferedReader ( Charsets . UTF _8 ) . use { it . readText ( ) }
analyticsResult = parseAnalyticsReport ( reportCsv ) ; statusMessage = " [ $dateString ] 분석 완료 "
} catch ( e : Exception ) { e . printStackTrace ( ) ; statusMessage = " 오류: ${e.message} " }
2025-09-11 15:49:10 +09:00
}
2025-09-11 16:32:24 +09:00
} , modifier = Modifier . fillMaxWidth ( ) , enabled = privateKey != null ) { Text ( " 앱 분석 통계 조회 " ) }
Spacer ( Modifier . height ( 16. dp ) ) ; Divider ( ) ; AnalyticsResultView ( statusMessage , analyticsResult ) { analyticsResult ?. let { onSaveCsvRequest ( appId , reportDate , it ) } }
2025-09-11 15:49:10 +09:00
}
@Composable
2025-09-11 16:32:24 +09:00
fun AnalyticsResultView ( statusMessage : String , result : AnalyticsResult ? , onSaveRequest : ( ) -> Unit ) {
Column ( Modifier . fillMaxSize ( ) . padding ( top = 16. dp ) ) {
Text ( statusMessage , style = MaterialTheme . typography . subtitle2 ) ; Spacer ( Modifier . height ( 16. dp ) )
2025-09-11 15:49:10 +09:00
if ( result != null ) {
2025-09-11 16:32:24 +09:00
Column ( modifier = Modifier . weight ( 1f ) , verticalArrangement = Arrangement . spacedBy ( 16. dp ) ) {
AnalyticsCard ( " 최초 설치 " , result . installations , MaterialTheme . colors . primary ) ; AnalyticsCard ( " 재다운로드 " , result . redownloads , Color ( 0xFF00897B ) ) ; AnalyticsCard ( " 업데이트 " , result . updates , Color ( 0xFF5E35B1 ) )
2025-09-11 15:49:10 +09:00
}
2025-09-11 16:32:24 +09:00
Spacer ( Modifier . height ( 16. dp ) ) ; Button ( onClick = onSaveRequest , modifier = Modifier . fillMaxWidth ( ) ) { Text ( " CSV로 저장 " ) }
2025-09-11 15:49:10 +09:00
}
}
}
@Composable
2025-09-11 16:32:24 +09:00
fun AnalyticsCard ( title : String , value : Long , color : Color ) { Card ( elevation = 4. dp , modifier = Modifier . fillMaxWidth ( ) ) { Column ( Modifier . padding ( 16. dp ) ) { Text ( title , style = MaterialTheme . typography . h6 ) ; Spacer ( Modifier . height ( 8. dp ) ) ; Text ( NumberFormat . getInstance ( ) . format ( value ) , style = MaterialTheme . typography . h4 . copy ( fontWeight = FontWeight . Bold ) , color = color ) } } }
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
LazyVerticalGrid ( columns = 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 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 ) ) {
App (
onSaveTsvRequest = { period , tsvData -> val dialog = FileDialog ( Frame ( ) , " TSV 파일로 저장 " , FileDialog . SAVE ) . apply { file = " sales-report- $period .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} " ) } } } } ,
onSaveCsvRequest = { appId , date , result -> val csvContent = " Metric,Count \n Installations, ${result.installations} \n Redownloads, ${result.redownloads} \n Updates, ${result.updates} " ; val dialog = FileDialog ( Frame ( ) , " CSV 파일로 저장 " , FileDialog . SAVE ) . apply { file = " analytics- $appId - ${date.format(DateTimeFormatter.ISO_LOCAL_DATE)} .csv " ; isVisible = true } ; if ( dialog . directory != null && dialog . file != null ) { try { Files . writeString ( Paths . get ( dialog . directory , dialog . file ) , csvContent ) ; scope . launch { snackbarHostState . showSnackbar ( " CSV 파일 저장 완료 " ) } } catch ( e : Exception ) { scope . launch { snackbarHostState . showSnackbar ( " 파일 저장 실패: ${e.message} " ) } } } }
)
}
}
2025-09-11 15:49:10 +09:00
}
}
}