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