899 lines
44 KiB
Kotlin
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}") }
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |