diff --git a/CHANGELOG.md b/CHANGELOG.md index 909d33a..18fcd44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,18 @@ ## [Unreleased] +## [1.3.2] - 2026-07-15 + +### Changed +- 샘플 앱을 차트 팔레트 기반 반응형 갤러리와 시나리오 선택형 상세 화면으로 개편 — 한 번에 하나의 예제만 렌더링하고 시스템 영역, 뒤로가기 접근성, 상태 복원을 개선 + +### Fixed +- Line/Bar/Donut/Pie/Radar 차트에 빈 `colors` 팔레트를 전달하면 발생하던 인덱싱 크래시를 기본 팔레트 fallback으로 방지 + ### Documentation - README와 내부 contributor 문서를 v1.3.1 릴리스 기준으로 정리 — 최신 dependency/version 표기, 성능 릴리스 설명, 릴리즈 문서 동기화 체크리스트 보강 - README `GridStyle.dashPattern` 예제를 현재 `List` API에 맞게 수정하고 mirrored example test 추가 +- 실제 Compose 렌더링으로 생성한 라이트·다크 차트 이미지 12장과 재현 가능한 opt-in 캡처 스크립트 추가 ## [1.3.1] - 2026-06-11 diff --git a/README.md b/README.md index 0665654..89cede4 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ Jetpack Compose에서 사용할 수 있는 가볍고 독립적인 차트 UI 컴 Donut Chart - - - + 라이트 모드 선 차트 + 라이트 모드 막대 차트 + 라이트 모드 도넛 차트 Gauge Chart @@ -27,9 +27,9 @@ Jetpack Compose에서 사용할 수 있는 가볍고 독립적인 차트 UI 컴 Pie Chart - - - + 라이트 모드 게이지 차트 + 라이트 모드 레이더 차트 + 라이트 모드 파이 차트 @@ -39,7 +39,18 @@ Jetpack Compose에서 사용할 수 있는 가볍고 독립적인 차트 UI 컴 레이더 웹 색상이 다크 모드에 자동 대응합니다. 다크 모드 전용 스타일 설정은 필요하지 않습니다. -> 📷 다크 모드 스크린샷 추가 예정: `screenshots/{chart}-dark.png` + + + + + + + + + + + +
다크 모드 선 차트다크 모드 막대 차트다크 모드 도넛 차트
다크 모드 게이지 차트다크 모드 레이더 차트다크 모드 파이 차트
## Why? @@ -55,12 +66,12 @@ Jetpack Compose에서 사용할 수 있는 가볍고 독립적인 차트 UI 컴 ## Setup -현재 문서 기준 최신 릴리스는 **v1.3.1**입니다. +현재 문서 기준 최신 릴리스는 **v1.3.2**입니다. ```kotlin // build.gradle.kts dependencies { - implementation("io.github.ois0886:compose-chart:1.3.1") + implementation("io.github.ois0886:compose-chart:1.3.2") } ``` @@ -68,7 +79,7 @@ dependencies { ### 선 차트 - +라이트 모드 선 차트 ```kotlin LineChart( @@ -82,7 +93,7 @@ LineChart( ### 막대 차트 - +라이트 모드 막대 차트 ```kotlin BarChart( @@ -96,7 +107,7 @@ BarChart( ### 도넛 차트 - +라이트 모드 도넛 차트 ```kotlin DonutChart( @@ -113,7 +124,7 @@ DonutChart( ### 게이지 차트 - +라이트 모드 게이지 차트 ```kotlin GaugeChart( @@ -124,7 +135,7 @@ GaugeChart( ### 레이더 차트 - +라이트 모드 레이더 차트 ```kotlin RadarChart( @@ -138,7 +149,7 @@ RadarChart( ### 파이 차트 - +라이트 모드 파이 차트 ```kotlin PieChart( @@ -147,10 +158,10 @@ PieChart( ) ``` -## Features (v1.3.1) +## Features (v1.3.2) README 예제 코드는 테스트에서도 함께 유지되어, 문서 스니펫이 실제 API와 어긋나지 않도록 관리합니다. -v1.3.1은 공개 API와 시각 결과를 유지하면서 Line/Bar/Donut/Pie/Radar 렌더링 hot path의 반복 계산과 할당을 줄인 성능 개선 릴리스입니다. +v1.3.2는 공개 API 시그니처를 유지하면서 빈 색상 팔레트를 안전한 기본 팔레트로 대체하고, 샘플 앱과 라이트·다크 차트 갤러리를 새로 정리한 패치 릴리스입니다. ### 줌 & 팬 @@ -273,6 +284,7 @@ LineChart( 기본 팔레트(`ChartDefaults.colors`)는 6개 색으로 구성되며, 멀티 시리즈 차트에서 순서대로 배정되고 시리즈 수가 6개를 넘으면 처음부터 다시 순환합니다. +`colors = emptyList()`를 전달해도 크래시하지 않고 이 기본 팔레트로 대체됩니다. | # | Name | HEX | |---|--------|-----------| diff --git a/app/src/androidTest/java/com/inseong/composechart/ChartGalleryScreenTest.kt b/app/src/androidTest/java/com/inseong/composechart/ChartGalleryScreenTest.kt index fe0ad4c..f74c720 100644 --- a/app/src/androidTest/java/com/inseong/composechart/ChartGalleryScreenTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/ChartGalleryScreenTest.kt @@ -1,8 +1,11 @@ package com.inseong.composechart import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode import com.inseong.composechart.ui.theme.ComposeChartTheme import org.junit.Rule import org.junit.Test @@ -32,6 +35,9 @@ class ChartGalleryScreenTest { composeTestRule.waitForIdle() ChartType.entries.forEach { chartType -> + composeTestRule + .onNodeWithTag("chart-gallery-grid") + .performScrollToNode(hasText(chartType.displayName)) composeTestRule.onNodeWithText(chartType.displayName).assertIsDisplayed() } } @@ -44,6 +50,6 @@ class ChartGalleryScreenTest { } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Compose Chart Gallery").assertIsDisplayed() + composeTestRule.onNodeWithText("COMPOSE CHART").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/inseong/composechart/NavigationTest.kt b/app/src/androidTest/java/com/inseong/composechart/NavigationTest.kt index 3d0ce0c..565d47c 100644 --- a/app/src/androidTest/java/com/inseong/composechart/NavigationTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/NavigationTest.kt @@ -1,9 +1,13 @@ package com.inseong.composechart import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode import com.inseong.composechart.ui.theme.ComposeChartTheme import org.junit.Rule import org.junit.Test @@ -19,9 +23,8 @@ class NavigationTest { ComposeChartTheme { ChartApp() } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Line Chart").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + openChart(ChartType.LINE) + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test @@ -30,9 +33,8 @@ class NavigationTest { ComposeChartTheme { ChartApp() } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Bar Chart").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + openChart(ChartType.BAR) + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test @@ -41,9 +43,8 @@ class NavigationTest { ComposeChartTheme { ChartApp() } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Donut Chart").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + openChart(ChartType.DONUT) + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test @@ -52,9 +53,8 @@ class NavigationTest { ComposeChartTheme { ChartApp() } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Gauge Chart").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + openChart(ChartType.GAUGE) + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test @@ -63,9 +63,8 @@ class NavigationTest { ComposeChartTheme { ChartApp() } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Radar Chart").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + openChart(ChartType.RADAR) + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test @@ -74,9 +73,8 @@ class NavigationTest { ComposeChartTheme { ChartApp() } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Pie Chart").performClick() - composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + openChart(ChartType.PIE) + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test @@ -85,10 +83,17 @@ class NavigationTest { ComposeChartTheme { ChartApp() } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Line Chart").performClick() + openChart(ChartType.LINE) + composeTestRule.onNodeWithContentDescription("Back to chart gallery").performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("\u2190").performClick() + composeTestRule.onNodeWithText("COMPOSE CHART").assertIsDisplayed() + } + + private fun openChart(chartType: ChartType) { + composeTestRule + .onNodeWithTag("chart-gallery-grid") + .performScrollToNode(hasText(chartType.displayName)) + composeTestRule.onNodeWithText(chartType.displayName).performClick() composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Compose Chart Gallery").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/inseong/composechart/ReadmeScreenshotTest.kt b/app/src/androidTest/java/com/inseong/composechart/ReadmeScreenshotTest.kt new file mode 100644 index 0000000..0c65de0 --- /dev/null +++ b/app/src/androidTest/java/com/inseong/composechart/ReadmeScreenshotTest.kt @@ -0,0 +1,170 @@ +package com.inseong.composechart + +import android.graphics.Bitmap +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.inseong.composechart.bar.BarChart +import com.inseong.composechart.donut.DonutChart +import com.inseong.composechart.gauge.GaugeChart +import com.inseong.composechart.line.LineChart +import com.inseong.composechart.pie.PieChart +import com.inseong.composechart.radar.RadarChart +import com.inseong.composechart.style.BarChartStyle +import com.inseong.composechart.style.DonutChartStyle +import com.inseong.composechart.style.GaugeChartStyle +import com.inseong.composechart.style.LineChartStyle +import com.inseong.composechart.style.PieChartStyle +import com.inseong.composechart.style.RadarChartStyle +import com.inseong.composechart.ui.theme.ComposeChartTheme +import java.io.File +import java.io.FileOutputStream +import java.util.Locale +import org.junit.Rule +import org.junit.Test + +private const val SNAPSHOT_TAG = "readme-chart-snapshot" + +class ReadmeScreenshotTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun captureReadmeCharts_whenExplicitlyRequested() { + val arguments = InstrumentationRegistry.getArguments() + if (arguments.getString(RECORD_ARGUMENT) != "true") return + + val themeName = requireNotNull(arguments.getString(THEME_ARGUMENT)) { + "-$THEME_ARGUMENT must be light or dark" + } + require(themeName in SUPPORTED_THEMES) { + "Unsupported screenshot theme: $themeName" + } + val darkTheme = themeName == DARK_THEME + var chartType by mutableStateOf(ChartType.LINE) + + composeTestRule.setContent { + ComposeChartTheme(darkTheme = darkTheme) { + ReadmeChartSnapshot(chartType = chartType) + } + } + + ChartType.entries.forEach { type -> + composeTestRule.runOnIdle { chartType = type } + composeTestRule.waitForIdle() + val outputFile = screenshotOutputFile( + chartType = type, + themeName = themeName, + ) + FileOutputStream(outputFile).use { output -> + val wasWritten = composeTestRule + .onNodeWithTag(SNAPSHOT_TAG) + .captureToImage() + .asAndroidBitmap() + .compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, output) + check(wasWritten) { "Failed to write ${outputFile.name}" } + } + } + } + + private fun screenshotOutputFile(chartType: ChartType, themeName: String): File { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val outputDirectory = File( + requireNotNull(context.getExternalFilesDir(null)), + OUTPUT_DIRECTORY, + ) + check(outputDirectory.isDirectory || outputDirectory.mkdirs()) { + "Failed to create screenshot directory: $outputDirectory" + } + val chartName = chartType.name.lowercase(Locale.ROOT) + return File(outputDirectory, "$chartName-chart-$themeName.png") + } + + private companion object { + const val RECORD_ARGUMENT = "recordReadmeScreenshots" + const val THEME_ARGUMENT = "screenshotTheme" + const val DARK_THEME = "dark" + const val OUTPUT_DIRECTORY = "readme-screenshots" + const val PNG_QUALITY = 100 + val SUPPORTED_THEMES = setOf("light", DARK_THEME) + } +} + +@Composable +private fun ReadmeChartSnapshot(chartType: ChartType) { + Box( + modifier = Modifier + .size(width = 360.dp, height = 280.dp) + .background(MaterialTheme.colorScheme.background) + .testTag(SNAPSHOT_TAG) + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + when (chartType) { + ChartType.LINE -> LineChart( + data = normalLineData, + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + style = LineChartStyle( + curved = true, + gradientFill = true, + animationDurationMs = 0, + ), + ) + + ChartType.BAR -> BarChart( + data = normalBarData, + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + style = BarChartStyle(animationDurationMs = 0), + ) + + ChartType.DONUT -> DonutChart( + data = normalDonutData, + modifier = Modifier.size(230.dp), + style = DonutChartStyle( + holeRadius = 0.6f, + animationDurationMs = 0, + ), + ) + + ChartType.GAUGE -> GaugeChart( + data = normalGaugeData, + modifier = Modifier.size(230.dp), + style = GaugeChartStyle(animationDurationMs = 0), + ) + + ChartType.RADAR -> RadarChart( + data = normalRadarData, + modifier = Modifier.size(230.dp), + style = RadarChartStyle(animationDurationMs = 0), + ) + + ChartType.PIE -> PieChart( + data = normalPieData, + modifier = Modifier.size(230.dp), + style = PieChartStyle(animationDurationMs = 0), + ) + } + } +} diff --git a/app/src/androidTest/java/com/inseong/composechart/screen/BarChartScreenTest.kt b/app/src/androidTest/java/com/inseong/composechart/screen/BarChartScreenTest.kt index 0d8df5d..21fb9cb 100644 --- a/app/src/androidTest/java/com/inseong/composechart/screen/BarChartScreenTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/screen/BarChartScreenTest.kt @@ -34,24 +34,24 @@ class BarChartScreenTest { } @Test - fun barChartScreen_showsNormalSection() { + fun barChartScreen_showsDefaultScenario() { composeTestRule.setContent { ComposeChartTheme { BarChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test - fun barChartScreen_showsLargeSizeSection() { + fun barChartScreen_showsExpandedScenario() { composeTestRule.setContent { ComposeChartTheme { BarChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Large Size (400dp)").assertIsDisplayed() + composeTestRule.onNodeWithText("Expanded").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/inseong/composechart/screen/DonutChartScreenTest.kt b/app/src/androidTest/java/com/inseong/composechart/screen/DonutChartScreenTest.kt index 9c1a93f..10d6462 100644 --- a/app/src/androidTest/java/com/inseong/composechart/screen/DonutChartScreenTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/screen/DonutChartScreenTest.kt @@ -34,24 +34,24 @@ class DonutChartScreenTest { } @Test - fun donutChartScreen_showsNormalSection() { + fun donutChartScreen_showsDefaultScenario() { composeTestRule.setContent { ComposeChartTheme { DonutChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test - fun donutChartScreen_showsLargeSizeSection() { + fun donutChartScreen_showsExpandedScenario() { composeTestRule.setContent { ComposeChartTheme { DonutChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Large Size (400dp)").assertIsDisplayed() + composeTestRule.onNodeWithText("Expanded").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/inseong/composechart/screen/GaugeChartScreenTest.kt b/app/src/androidTest/java/com/inseong/composechart/screen/GaugeChartScreenTest.kt index 94ace98..e04bb96 100644 --- a/app/src/androidTest/java/com/inseong/composechart/screen/GaugeChartScreenTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/screen/GaugeChartScreenTest.kt @@ -34,24 +34,24 @@ class GaugeChartScreenTest { } @Test - fun gaugeChartScreen_showsNormalSection() { + fun gaugeChartScreen_showsDefaultScenario() { composeTestRule.setContent { ComposeChartTheme { GaugeChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test - fun gaugeChartScreen_showsLargeSizeSection() { + fun gaugeChartScreen_showsExpandedScenario() { composeTestRule.setContent { ComposeChartTheme { GaugeChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Large Size (400dp)").assertIsDisplayed() + composeTestRule.onNodeWithText("Expanded").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/inseong/composechart/screen/LineChartScreenTest.kt b/app/src/androidTest/java/com/inseong/composechart/screen/LineChartScreenTest.kt index f65b2ab..bfc472a 100644 --- a/app/src/androidTest/java/com/inseong/composechart/screen/LineChartScreenTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/screen/LineChartScreenTest.kt @@ -1,8 +1,12 @@ package com.inseong.composechart.screen import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode import com.inseong.composechart.ui.theme.ComposeChartTheme import org.junit.Rule import org.junit.Test @@ -34,24 +38,42 @@ class LineChartScreenTest { } @Test - fun lineChartScreen_showsNormalSection() { + fun lineChartScreen_showsDefaultScenario() { composeTestRule.setContent { ComposeChartTheme { LineChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test - fun lineChartScreen_showsLargeSizeSection() { + fun lineChartScreen_showsExpandedScenario() { composeTestRule.setContent { ComposeChartTheme { LineChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Large Size (400dp)").assertIsDisplayed() + composeTestRule.onNodeWithText("Expanded").assertIsDisplayed() + } + + @Test + fun lineChartScreen_selectExtremeValues_updatesScenarioDescription() { + composeTestRule.setContent { + ComposeChartTheme { + LineChartScreen(onBack = {}) + } + } + + composeTestRule + .onNodeWithTag("chart-scenario-picker") + .performScrollToNode(hasText("Extreme values")) + composeTestRule.onNodeWithText("Extreme values").performClick() + + composeTestRule + .onNodeWithText("A wide numerical range for validating scale and normalization.") + .assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/inseong/composechart/screen/PieChartScreenTest.kt b/app/src/androidTest/java/com/inseong/composechart/screen/PieChartScreenTest.kt index 6c3dc6b..48b8892 100644 --- a/app/src/androidTest/java/com/inseong/composechart/screen/PieChartScreenTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/screen/PieChartScreenTest.kt @@ -34,24 +34,24 @@ class PieChartScreenTest { } @Test - fun pieChartScreen_showsNormalSection() { + fun pieChartScreen_showsDefaultScenario() { composeTestRule.setContent { ComposeChartTheme { PieChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test - fun pieChartScreen_showsLargeSizeSection() { + fun pieChartScreen_showsExpandedScenario() { composeTestRule.setContent { ComposeChartTheme { PieChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Large Size (400dp)").assertIsDisplayed() + composeTestRule.onNodeWithText("Expanded").assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/inseong/composechart/screen/RadarChartScreenTest.kt b/app/src/androidTest/java/com/inseong/composechart/screen/RadarChartScreenTest.kt index fbc32a3..73a380d 100644 --- a/app/src/androidTest/java/com/inseong/composechart/screen/RadarChartScreenTest.kt +++ b/app/src/androidTest/java/com/inseong/composechart/screen/RadarChartScreenTest.kt @@ -34,24 +34,24 @@ class RadarChartScreenTest { } @Test - fun radarChartScreen_showsNormalSection() { + fun radarChartScreen_showsDefaultScenario() { composeTestRule.setContent { ComposeChartTheme { RadarChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Normal").assertIsDisplayed() + composeTestRule.onNodeWithText("Default").assertIsDisplayed() } @Test - fun radarChartScreen_showsLargeSizeSection() { + fun radarChartScreen_showsExpandedScenario() { composeTestRule.setContent { ComposeChartTheme { RadarChartScreen(onBack = {}) } } composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Large Size (400dp)").assertIsDisplayed() + composeTestRule.onNodeWithText("Expanded").assertIsDisplayed() } } diff --git a/app/src/main/java/com/inseong/composechart/ChartCatalog.kt b/app/src/main/java/com/inseong/composechart/ChartCatalog.kt new file mode 100644 index 0000000..a7fe411 --- /dev/null +++ b/app/src/main/java/com/inseong/composechart/ChartCatalog.kt @@ -0,0 +1,47 @@ +package com.inseong.composechart + +import androidx.compose.ui.graphics.Color + +enum class ChartType( + val displayName: String, + val category: String, + val description: String, + val accentColor: Color, +) { + LINE( + displayName = "Line Chart", + category = "Trend", + description = "Track change and movement over time.", + accentColor = ChartDefaults.colors[0], + ), + BAR( + displayName = "Bar Chart", + category = "Comparison", + description = "Compare categories at a glance.", + accentColor = ChartDefaults.colors[4], + ), + DONUT( + displayName = "Donut Chart", + category = "Composition", + description = "Show how each part contributes to a whole.", + accentColor = ChartDefaults.colors[2], + ), + GAUGE( + displayName = "Gauge Chart", + category = "Progress", + description = "Communicate progress toward a target.", + accentColor = ChartDefaults.colors[1], + ), + RADAR( + displayName = "Radar Chart", + category = "Profile", + description = "Compare a profile across several dimensions.", + accentColor = ChartDefaults.colors[3], + ), + PIE( + displayName = "Pie Chart", + category = "Composition", + description = "Present a compact proportional breakdown.", + accentColor = ChartDefaults.colors[5], + ), +} diff --git a/app/src/main/java/com/inseong/composechart/ChartGalleryScreen.kt b/app/src/main/java/com/inseong/composechart/ChartGalleryScreen.kt index b6f48f4..373ae90 100644 --- a/app/src/main/java/com/inseong/composechart/ChartGalleryScreen.kt +++ b/app/src/main/java/com/inseong/composechart/ChartGalleryScreen.kt @@ -1,21 +1,38 @@ package com.inseong.composechart +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable @@ -23,37 +40,384 @@ fun ChartGalleryScreen( onChartSelected: (ChartType) -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 156.dp), + modifier = modifier + .fillMaxSize() + .testTag("chart-gallery-grid"), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + GalleryHero() + } + item(span = { GridItemSpan(maxLineSpan) }) { + GallerySectionHeader() + } + items( + items = ChartType.entries.toList(), + key = ChartType::name, + ) { chartType -> + ChartTypeCard( + chartType = chartType, + onClick = { onChartSelected(chartType) }, + ) + } + } +} + +@Composable +private fun GalleryHero() { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = MaterialTheme.shapes.extraLarge, + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 26.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "COMPOSE CHART", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Build clearer stories\nwith data.", + style = MaterialTheme.typography.displaySmall, + ) + Text( + text = "Explore lightweight, theme-aware charts and test how they behave with real-world edge cases.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GalleryBadge(text = "6 chart types") + GalleryBadge(text = "Compose-first") + } + } + } +} + +@Composable +private fun GalleryBadge(text: String) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(999.dp), + ) { Text( - text = "Compose Chart Gallery", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(16.dp), + text = text, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), ) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + } +} + +@Composable +private fun GallerySectionHeader() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "Explore charts", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = "Choose a chart to open its interactive test bench.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(999.dp), + ) { + Text( + text = ChartType.entries.size.toString(), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } +} + +@Composable +private fun ChartTypeCard( + chartType: ChartType, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(258.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation( + defaultElevation = 0.dp, + pressedElevation = 2.dp, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { - items(ChartType.entries.toList()) { chartType -> - Card( - onClick = { onChartSelected(chartType) }, - modifier = Modifier - .fillMaxWidth() - .height(80.dp), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + color = chartType.accentColor.copy(alpha = 0.12f), + contentColor = chartType.accentColor, + shape = RoundedCornerShape(999.dp), ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = chartType.displayName, - style = MaterialTheme.typography.titleMedium, - ) - } + Text( + text = chartType.category, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + ) + } + Text( + text = "↗", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clearAndSetSemantics { }, + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(96.dp), + contentAlignment = Alignment.Center, + ) { + ChartArtwork( + chartType = chartType, + modifier = Modifier.fillMaxSize(), + ) + } + Text( + text = chartType.displayName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = chartType.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ChartArtwork( + chartType: ChartType, + modifier: Modifier = Modifier, +) { + val trackColor = MaterialTheme.colorScheme.outlineVariant + val accentColor = chartType.accentColor + + Canvas( + modifier = modifier + .clearAndSetSemantics { } + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + val thinStroke = 1.5.dp.toPx() + val strongStroke = 4.dp.toPx() + + when (chartType) { + ChartType.LINE -> { + drawLine( + color = trackColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = thinStroke, + ) + val path = Path().apply { + moveTo(0f, size.height * 0.72f) + cubicTo( + size.width * 0.2f, + size.height * 0.86f, + size.width * 0.28f, + size.height * 0.32f, + size.width * 0.44f, + size.height * 0.46f, + ) + cubicTo( + size.width * 0.62f, + size.height * 0.62f, + size.width * 0.72f, + size.height * 0.14f, + size.width, + size.height * 0.24f, + ) + } + drawPath( + path = path, + color = accentColor, + style = Stroke( + width = strongStroke, + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + } + + ChartType.BAR -> { + val values = listOf(0.42f, 0.74f, 0.58f, 0.9f, 0.66f) + val gap = size.width * 0.06f + val barWidth = (size.width - gap * (values.size - 1)) / values.size + values.forEachIndexed { index, value -> + val left = index * (barWidth + gap) + drawRoundRect( + color = accentColor.copy(alpha = 0.55f + index * 0.08f), + topLeft = Offset(left, size.height * (1f - value)), + size = androidx.compose.ui.geometry.Size(barWidth, size.height * value), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(6.dp.toPx()), + ) } } + + ChartType.DONUT -> { + val diameter = minOf(size.width, size.height) * 0.82f + val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2) + val arcSize = androidx.compose.ui.geometry.Size(diameter, diameter) + drawArc( + color = trackColor, + startAngle = -90f, + sweepAngle = 360f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = 12.dp.toPx()), + ) + drawArc( + color = accentColor, + startAngle = -90f, + sweepAngle = 258f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = 12.dp.toPx(), cap = StrokeCap.Round), + ) + } + + ChartType.GAUGE -> { + val diameter = minOf(size.width, size.height) * 0.86f + val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2) + val arcSize = androidx.compose.ui.geometry.Size(diameter, diameter) + drawArc( + color = trackColor, + startAngle = 150f, + sweepAngle = 240f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = 10.dp.toPx(), cap = StrokeCap.Round), + ) + drawArc( + color = accentColor, + startAngle = 150f, + sweepAngle = 174f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = 10.dp.toPx(), cap = StrokeCap.Round), + ) + } + + ChartType.RADAR -> { + val webPoints = listOf( + Offset(size.width * 0.5f, size.height * 0.05f), + Offset(size.width * 0.95f, size.height * 0.4f), + Offset(size.width * 0.78f, size.height * 0.94f), + Offset(size.width * 0.22f, size.height * 0.94f), + Offset(size.width * 0.05f, size.height * 0.4f), + ) + val dataPoints = listOf( + Offset(size.width * 0.5f, size.height * 0.16f), + Offset(size.width * 0.83f, size.height * 0.43f), + Offset(size.width * 0.67f, size.height * 0.79f), + Offset(size.width * 0.31f, size.height * 0.82f), + Offset(size.width * 0.22f, size.height * 0.44f), + ) + val webPath = webPoints.toClosedPath() + val dataPath = dataPoints.toClosedPath() + drawPath(path = webPath, color = trackColor, style = Stroke(width = thinStroke)) + drawPath(path = dataPath, color = accentColor.copy(alpha = 0.2f), style = Fill) + drawPath( + path = dataPath, + color = accentColor, + style = Stroke(width = strongStroke, join = StrokeJoin.Round), + ) + } + + ChartType.PIE -> { + val diameter = minOf(size.width, size.height) * 0.84f + val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2) + val arcSize = androidx.compose.ui.geometry.Size(diameter, diameter) + drawArc( + color = accentColor.copy(alpha = 0.35f), + startAngle = -90f, + sweepAngle = 110f, + useCenter = true, + topLeft = topLeft, + size = arcSize, + ) + drawArc( + color = accentColor.copy(alpha = 0.65f), + startAngle = 20f, + sweepAngle = 85f, + useCenter = true, + topLeft = topLeft, + size = arcSize, + ) + drawArc( + color = accentColor, + startAngle = 105f, + sweepAngle = 135f, + useCenter = true, + topLeft = topLeft, + size = arcSize, + ) + drawArc( + color = accentColor.copy(alpha = 0.82f), + startAngle = 240f, + sweepAngle = 30f, + useCenter = true, + topLeft = topLeft, + size = arcSize, + ) + } + } + } +} + +private fun List.toClosedPath(): Path = Path().also { path -> + forEachIndexed { index, point -> + if (index == 0) { + path.moveTo(point.x, point.y) + } else { + path.lineTo(point.x, point.y) } } + path.close() } diff --git a/app/src/main/java/com/inseong/composechart/MainActivity.kt b/app/src/main/java/com/inseong/composechart/MainActivity.kt index 21de83b..d0097a5 100644 --- a/app/src/main/java/com/inseong/composechart/MainActivity.kt +++ b/app/src/main/java/com/inseong/composechart/MainActivity.kt @@ -39,19 +39,39 @@ class MainActivity : ComponentActivity() { @Composable fun ChartApp(modifier: Modifier = Modifier) { var currentScreenName by rememberSaveable { mutableStateOf(null) } - val currentScreen = currentScreenName?.let(ChartType::valueOf) + val currentScreen = currentScreenName?.let { savedName -> + ChartType.entries.firstOrNull { it.name == savedName } + } when (currentScreen) { null -> ChartGalleryScreen( onChartSelected = { currentScreenName = it.name }, modifier = modifier, ) - ChartType.LINE -> LineChartScreen(onBack = { currentScreenName = null }) - ChartType.BAR -> BarChartScreen(onBack = { currentScreenName = null }) - ChartType.DONUT -> DonutChartScreen(onBack = { currentScreenName = null }) - ChartType.GAUGE -> GaugeChartScreen(onBack = { currentScreenName = null }) - ChartType.RADAR -> RadarChartScreen(onBack = { currentScreenName = null }) - ChartType.PIE -> PieChartScreen(onBack = { currentScreenName = null }) + ChartType.LINE -> LineChartScreen( + onBack = { currentScreenName = null }, + modifier = modifier, + ) + ChartType.BAR -> BarChartScreen( + onBack = { currentScreenName = null }, + modifier = modifier, + ) + ChartType.DONUT -> DonutChartScreen( + onBack = { currentScreenName = null }, + modifier = modifier, + ) + ChartType.GAUGE -> GaugeChartScreen( + onBack = { currentScreenName = null }, + modifier = modifier, + ) + ChartType.RADAR -> RadarChartScreen( + onBack = { currentScreenName = null }, + modifier = modifier, + ) + ChartType.PIE -> PieChartScreen( + onBack = { currentScreenName = null }, + modifier = modifier, + ) } } diff --git a/app/src/main/java/com/inseong/composechart/SampleData.kt b/app/src/main/java/com/inseong/composechart/SampleData.kt index 5de0994..7109b25 100644 --- a/app/src/main/java/com/inseong/composechart/SampleData.kt +++ b/app/src/main/java/com/inseong/composechart/SampleData.kt @@ -8,15 +8,6 @@ import com.inseong.composechart.data.LineChartData import com.inseong.composechart.data.RadarChartData import com.inseong.composechart.data.RadarEntry -enum class ChartType(val displayName: String) { - LINE("Line Chart"), - BAR("Bar Chart"), - DONUT("Donut Chart"), - GAUGE("Gauge Chart"), - RADAR("Radar Chart"), - PIE("Pie Chart"), -} - // ── Normal Data ── val normalLineData = LineChartData.fromValues( diff --git a/app/src/main/java/com/inseong/composechart/screen/BarChartScreen.kt b/app/src/main/java/com/inseong/composechart/screen/BarChartScreen.kt index 8819569..9b14a58 100644 --- a/app/src/main/java/com/inseong/composechart/screen/BarChartScreen.kt +++ b/app/src/main/java/com/inseong/composechart/screen/BarChartScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.inseong.composechart.bar.BarChart import com.inseong.composechart.emptyBarData import com.inseong.composechart.extremeBarData @@ -12,46 +11,30 @@ import com.inseong.composechart.invalidBarData import com.inseong.composechart.normalBarData @Composable -fun BarChartScreen(onBack: () -> Unit) { +fun BarChartScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { ChartShowcaseScreen( title = "Bar Chart", + description = "Compare categories while testing bar spacing, scale extremes, and constrained layouts.", onBack = onBack, - ) { - ChartShowcaseSection(title = "Normal") { - BarChart( - data = normalBarData, - modifier = Modifier.fillMaxWidth().height(200.dp), - ) - } - ChartShowcaseSection(title = "Large Size (400dp)") { - BarChart( - data = normalBarData, - modifier = Modifier.fillMaxWidth().height(400.dp), - ) - } - ChartShowcaseSection(title = "Small Size (60dp)") { - BarChart( - data = normalBarData, - modifier = Modifier.fillMaxWidth().height(60.dp), - ) - } - ChartShowcaseSection(title = "Extreme Values (1 ~ 10000)") { - BarChart( - data = extremeBarData, - modifier = Modifier.fillMaxWidth().height(200.dp), - ) - } - ChartShowcaseSection(title = "Invalid Data (NaN / Infinity)") { - BarChart( - data = invalidBarData, - modifier = Modifier.fillMaxWidth().height(200.dp), - ) - } - ChartShowcaseSection(title = "Empty Data") { - BarChart( - data = emptyBarData, - modifier = Modifier.fillMaxWidth().height(200.dp), - ) + modifier = modifier, + ) { sample -> + val data = when (sample) { + ChartSample.DEFAULT, + ChartSample.EXPANDED, + ChartSample.COMPACT -> normalBarData + ChartSample.EXTREME -> extremeBarData + ChartSample.INVALID -> invalidBarData + ChartSample.EMPTY -> emptyBarData } + BarChart( + data = data, + modifier = Modifier + .fillMaxWidth() + .height(sample.chartDimension), + accessibilityLabel = "Bar chart preview", + ) } } diff --git a/app/src/main/java/com/inseong/composechart/screen/ChartShowcaseScreen.kt b/app/src/main/java/com/inseong/composechart/screen/ChartShowcaseScreen.kt index 9b5fa69..548ab0f 100644 --- a/app/src/main/java/com/inseong/composechart/screen/ChartShowcaseScreen.kt +++ b/app/src/main/java/com/inseong/composechart/screen/ChartShowcaseScreen.kt @@ -1,67 +1,275 @@ package com.inseong.composechart.screen import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +internal enum class ChartSample( + val displayName: String, + val description: String, + val chartDimension: Dp, +) { + DEFAULT( + displayName = "Default", + description = "Balanced defaults for a typical production layout.", + chartDimension = 220.dp, + ), + EXPANDED( + displayName = "Expanded", + description = "A larger canvas for checking spacing, labels, and visual rhythm.", + chartDimension = 360.dp, + ), + COMPACT( + displayName = "Compact", + description = "A constrained canvas that reveals minimum-size behavior.", + chartDimension = 80.dp, + ), + EXTREME( + displayName = "Extreme values", + description = "A wide numerical range for validating scale and normalization.", + chartDimension = 220.dp, + ), + INVALID( + displayName = "Invalid input", + description = "NaN, infinity, and negative values exercise defensive handling.", + chartDimension = 220.dp, + ), + EMPTY( + displayName = "Empty data", + description = "An empty dataset confirms the chart remains stable without content.", + chartDimension = 220.dp, + ); + + val previewHeight: Dp + get() = if (this == COMPACT) 176.dp else chartDimension + 48.dp +} @Composable -fun ChartShowcaseScreen( +internal fun ChartShowcaseScreen( title: String, + description: String, onBack: () -> Unit, - content: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.(ChartSample) -> Unit, ) { BackHandler(onBack = onBack) + var selectedSampleName by rememberSaveable(title) { mutableStateOf(ChartSample.DEFAULT.name) } + val selectedSample = ChartSample.entries.firstOrNull { it.name == selectedSampleName } + ?: ChartSample.DEFAULT + Column( - modifier = Modifier + modifier = modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .background(MaterialTheme.colorScheme.background), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), + ShowcaseTopBar( + title = title, + onBack = onBack, + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, top = 20.dp, end = 16.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - IconButton(onClick = onBack) { Text("←") } - Text(text = title, style = MaterialTheme.typography.headlineSmall) + item { + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = "Test scenario", + style = MaterialTheme.typography.titleMedium, + ) + LazyRow( + modifier = Modifier.testTag("chart-scenario-picker"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = ChartSample.entries.toList(), + key = ChartSample::name, + ) { sample -> + FilterChip( + selected = sample == selectedSample, + onClick = { selectedSampleName = sample.name }, + label = { Text(sample.displayName) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + } + } + item { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.large, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + contentColor = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(999.dp), + ) { + Text( + text = "LIVE PREVIEW", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + Row( + modifier = Modifier.clearAndSetSemantics { }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = MaterialTheme.colorScheme.secondary, + shape = CircleShape, + ), + ) + Text( + text = "Ready", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + text = selectedSample.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(selectedSample.previewHeight) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + shape = MaterialTheme.shapes.medium, + ) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + content(selectedSample) + } + } + } + } + item { + Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "i", + fontWeight = FontWeight.Bold, + modifier = Modifier.clearAndSetSemantics { }, + ) + Text( + text = "Switch scenarios to inspect resizing and defensive input handling without rendering every chart at once.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } - Spacer(modifier = Modifier.height(16.dp)) - content() } } @Composable -fun ChartShowcaseSection( +private fun ShowcaseTopBar( title: String, - content: @Composable () -> Unit, + onBack: () -> Unit, ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + Surface( + color = MaterialTheme.colorScheme.surface, + shadowElevation = 1.dp, ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth(), - ) - content() + Row( + modifier = Modifier + .fillMaxWidth() + .height(68.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onBack, + modifier = Modifier.semantics { + contentDescription = "Back to chart gallery" + }, + ) { + Text( + text = "←", + fontSize = 24.sp, + modifier = Modifier.clearAndSetSemantics { }, + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 4.dp), + ) + } } - Spacer(modifier = Modifier.height(32.dp)) } diff --git a/app/src/main/java/com/inseong/composechart/screen/DonutChartScreen.kt b/app/src/main/java/com/inseong/composechart/screen/DonutChartScreen.kt index d177774..1e5addb 100644 --- a/app/src/main/java/com/inseong/composechart/screen/DonutChartScreen.kt +++ b/app/src/main/java/com/inseong/composechart/screen/DonutChartScreen.kt @@ -3,7 +3,6 @@ package com.inseong.composechart.screen import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.inseong.composechart.donut.DonutChart import com.inseong.composechart.emptyDonutData import com.inseong.composechart.extremeDonutData @@ -11,53 +10,32 @@ import com.inseong.composechart.invalidDonutData import com.inseong.composechart.normalDonutData import com.inseong.composechart.style.DonutChartStyle +private val showcaseDonutStyle = DonutChartStyle(holeRadius = 0.6f) + @Composable -fun DonutChartScreen(onBack: () -> Unit) { +fun DonutChartScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { ChartShowcaseScreen( title = "Donut Chart", + description = "Inspect proportional slices, label fit, selection spacing, and malformed values.", onBack = onBack, - ) { - ChartShowcaseSection(title = "Normal") { - DonutChart( - data = normalDonutData, - modifier = Modifier.size(200.dp), - style = DonutChartStyle(holeRadius = 0.6f), - ) - } - ChartShowcaseSection(title = "Large Size (400dp)") { - DonutChart( - data = normalDonutData, - modifier = Modifier.size(350.dp), - style = DonutChartStyle(holeRadius = 0.6f), - ) - } - ChartShowcaseSection(title = "Small Size (60dp)") { - DonutChart( - data = normalDonutData, - modifier = Modifier.size(60.dp), - style = DonutChartStyle(holeRadius = 0.6f), - ) - } - ChartShowcaseSection(title = "Extreme Values (1 ~ 10000)") { - DonutChart( - data = extremeDonutData, - modifier = Modifier.size(200.dp), - style = DonutChartStyle(holeRadius = 0.6f), - ) - } - ChartShowcaseSection(title = "Invalid Data (NaN / Infinity)") { - DonutChart( - data = invalidDonutData, - modifier = Modifier.size(200.dp), - style = DonutChartStyle(holeRadius = 0.6f), - ) - } - ChartShowcaseSection(title = "Empty Data") { - DonutChart( - data = emptyDonutData, - modifier = Modifier.size(200.dp), - style = DonutChartStyle(holeRadius = 0.6f), - ) + modifier = modifier, + ) { sample -> + val data = when (sample) { + ChartSample.DEFAULT, + ChartSample.EXPANDED, + ChartSample.COMPACT -> normalDonutData + ChartSample.EXTREME -> extremeDonutData + ChartSample.INVALID -> invalidDonutData + ChartSample.EMPTY -> emptyDonutData } + DonutChart( + data = data, + modifier = Modifier.size(sample.chartDimension), + style = showcaseDonutStyle, + accessibilityLabel = "Donut chart preview", + ) } } diff --git a/app/src/main/java/com/inseong/composechart/screen/GaugeChartScreen.kt b/app/src/main/java/com/inseong/composechart/screen/GaugeChartScreen.kt index 9617bd0..350bf50 100644 --- a/app/src/main/java/com/inseong/composechart/screen/GaugeChartScreen.kt +++ b/app/src/main/java/com/inseong/composechart/screen/GaugeChartScreen.kt @@ -3,7 +3,6 @@ package com.inseong.composechart.screen import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.inseong.composechart.emptyGaugeData import com.inseong.composechart.extremeGaugeData import com.inseong.composechart.gauge.GaugeChart @@ -11,46 +10,28 @@ import com.inseong.composechart.invalidGaugeData import com.inseong.composechart.normalGaugeData @Composable -fun GaugeChartScreen(onBack: () -> Unit) { +fun GaugeChartScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { ChartShowcaseScreen( title = "Gauge Chart", + description = "Review progress communication, center-label scaling, and invalid target handling.", onBack = onBack, - ) { - ChartShowcaseSection(title = "Normal") { - GaugeChart( - data = normalGaugeData, - modifier = Modifier.size(180.dp), - ) - } - ChartShowcaseSection(title = "Large Size (400dp)") { - GaugeChart( - data = normalGaugeData, - modifier = Modifier.size(350.dp), - ) - } - ChartShowcaseSection(title = "Small Size (60dp)") { - GaugeChart( - data = normalGaugeData, - modifier = Modifier.size(60.dp), - ) - } - ChartShowcaseSection(title = "Extreme Values (1 ~ 10000)") { - GaugeChart( - data = extremeGaugeData, - modifier = Modifier.size(180.dp), - ) - } - ChartShowcaseSection(title = "Invalid Data (NaN / Infinity)") { - GaugeChart( - data = invalidGaugeData, - modifier = Modifier.size(180.dp), - ) - } - ChartShowcaseSection(title = "Empty Data") { - GaugeChart( - data = emptyGaugeData, - modifier = Modifier.size(180.dp), - ) + modifier = modifier, + ) { sample -> + val data = when (sample) { + ChartSample.DEFAULT, + ChartSample.EXPANDED, + ChartSample.COMPACT -> normalGaugeData + ChartSample.EXTREME -> extremeGaugeData + ChartSample.INVALID -> invalidGaugeData + ChartSample.EMPTY -> emptyGaugeData } + GaugeChart( + data = data, + modifier = Modifier.size(sample.chartDimension), + accessibilityLabel = "Gauge chart preview", + ) } } diff --git a/app/src/main/java/com/inseong/composechart/screen/LineChartScreen.kt b/app/src/main/java/com/inseong/composechart/screen/LineChartScreen.kt index f6a7d2c..cbfc2d5 100644 --- a/app/src/main/java/com/inseong/composechart/screen/LineChartScreen.kt +++ b/app/src/main/java/com/inseong/composechart/screen/LineChartScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.inseong.composechart.emptyLineData import com.inseong.composechart.extremeLineData import com.inseong.composechart.invalidLineData @@ -12,53 +11,37 @@ import com.inseong.composechart.line.LineChart import com.inseong.composechart.normalLineData import com.inseong.composechart.style.LineChartStyle +private val showcaseLineStyle = LineChartStyle( + curved = true, + gradientFill = true, +) + @Composable -fun LineChartScreen(onBack: () -> Unit) { +fun LineChartScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { ChartShowcaseScreen( title = "Line Chart", + description = "Explore smooth trends, gradient fills, scaling behavior, and resilient input handling.", onBack = onBack, - ) { - ChartShowcaseSection(title = "Normal") { - LineChart( - data = normalLineData, - modifier = Modifier.fillMaxWidth().height(200.dp), - style = LineChartStyle(curved = true, gradientFill = true), - ) - } - ChartShowcaseSection(title = "Large Size (400dp)") { - LineChart( - data = normalLineData, - modifier = Modifier.fillMaxWidth().height(400.dp), - style = LineChartStyle(curved = true, gradientFill = true), - ) - } - ChartShowcaseSection(title = "Small Size (60dp)") { - LineChart( - data = normalLineData, - modifier = Modifier.fillMaxWidth().height(60.dp), - style = LineChartStyle(curved = true, gradientFill = true), - ) - } - ChartShowcaseSection(title = "Extreme Values (1 ~ 10000)") { - LineChart( - data = extremeLineData, - modifier = Modifier.fillMaxWidth().height(200.dp), - style = LineChartStyle(curved = true, gradientFill = true), - ) - } - ChartShowcaseSection(title = "Invalid Data (NaN / Infinity)") { - LineChart( - data = invalidLineData, - modifier = Modifier.fillMaxWidth().height(200.dp), - style = LineChartStyle(curved = true, gradientFill = true), - ) - } - ChartShowcaseSection(title = "Empty Data") { - LineChart( - data = emptyLineData, - modifier = Modifier.fillMaxWidth().height(200.dp), - style = LineChartStyle(curved = true, gradientFill = true), - ) + modifier = modifier, + ) { sample -> + val data = when (sample) { + ChartSample.DEFAULT, + ChartSample.EXPANDED, + ChartSample.COMPACT -> normalLineData + ChartSample.EXTREME -> extremeLineData + ChartSample.INVALID -> invalidLineData + ChartSample.EMPTY -> emptyLineData } + LineChart( + data = data, + modifier = Modifier + .fillMaxWidth() + .height(sample.chartDimension), + style = showcaseLineStyle, + accessibilityLabel = "Line chart preview", + ) } } diff --git a/app/src/main/java/com/inseong/composechart/screen/PieChartScreen.kt b/app/src/main/java/com/inseong/composechart/screen/PieChartScreen.kt index e4902b3..853c5e7 100644 --- a/app/src/main/java/com/inseong/composechart/screen/PieChartScreen.kt +++ b/app/src/main/java/com/inseong/composechart/screen/PieChartScreen.kt @@ -3,7 +3,6 @@ package com.inseong.composechart.screen import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.inseong.composechart.emptyPieData import com.inseong.composechart.extremePieData import com.inseong.composechart.invalidPieData @@ -11,46 +10,28 @@ import com.inseong.composechart.normalPieData import com.inseong.composechart.pie.PieChart @Composable -fun PieChartScreen(onBack: () -> Unit) { +fun PieChartScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { ChartShowcaseScreen( title = "Pie Chart", + description = "Examine compact proportional layouts, labels, and safe handling of unusable slices.", onBack = onBack, - ) { - ChartShowcaseSection(title = "Normal") { - PieChart( - data = normalPieData, - modifier = Modifier.size(200.dp), - ) - } - ChartShowcaseSection(title = "Large Size (400dp)") { - PieChart( - data = normalPieData, - modifier = Modifier.size(350.dp), - ) - } - ChartShowcaseSection(title = "Small Size (60dp)") { - PieChart( - data = normalPieData, - modifier = Modifier.size(60.dp), - ) - } - ChartShowcaseSection(title = "Extreme Values (1 ~ 10000)") { - PieChart( - data = extremePieData, - modifier = Modifier.size(200.dp), - ) - } - ChartShowcaseSection(title = "Invalid Data (NaN / Infinity)") { - PieChart( - data = invalidPieData, - modifier = Modifier.size(200.dp), - ) - } - ChartShowcaseSection(title = "Empty Data") { - PieChart( - data = emptyPieData, - modifier = Modifier.size(200.dp), - ) + modifier = modifier, + ) { sample -> + val data = when (sample) { + ChartSample.DEFAULT, + ChartSample.EXPANDED, + ChartSample.COMPACT -> normalPieData + ChartSample.EXTREME -> extremePieData + ChartSample.INVALID -> invalidPieData + ChartSample.EMPTY -> emptyPieData } + PieChart( + data = data, + modifier = Modifier.size(sample.chartDimension), + accessibilityLabel = "Pie chart preview", + ) } } diff --git a/app/src/main/java/com/inseong/composechart/screen/RadarChartScreen.kt b/app/src/main/java/com/inseong/composechart/screen/RadarChartScreen.kt index 085b9c0..f5a3125 100644 --- a/app/src/main/java/com/inseong/composechart/screen/RadarChartScreen.kt +++ b/app/src/main/java/com/inseong/composechart/screen/RadarChartScreen.kt @@ -3,7 +3,6 @@ package com.inseong.composechart.screen import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.inseong.composechart.emptyRadarData import com.inseong.composechart.extremeRadarData import com.inseong.composechart.invalidRadarData @@ -11,46 +10,28 @@ import com.inseong.composechart.normalRadarData import com.inseong.composechart.radar.RadarChart @Composable -fun RadarChartScreen(onBack: () -> Unit) { +fun RadarChartScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { ChartShowcaseScreen( title = "Radar Chart", + description = "Compare multi-axis profiles and verify web geometry across difficult datasets.", onBack = onBack, - ) { - ChartShowcaseSection(title = "Normal") { - RadarChart( - data = normalRadarData, - modifier = Modifier.size(200.dp), - ) - } - ChartShowcaseSection(title = "Large Size (400dp)") { - RadarChart( - data = normalRadarData, - modifier = Modifier.size(350.dp), - ) - } - ChartShowcaseSection(title = "Small Size (60dp)") { - RadarChart( - data = normalRadarData, - modifier = Modifier.size(60.dp), - ) - } - ChartShowcaseSection(title = "Extreme Values (1 ~ 10000)") { - RadarChart( - data = extremeRadarData, - modifier = Modifier.size(200.dp), - ) - } - ChartShowcaseSection(title = "Invalid Data (NaN / Infinity)") { - RadarChart( - data = invalidRadarData, - modifier = Modifier.size(200.dp), - ) - } - ChartShowcaseSection(title = "Empty Data") { - RadarChart( - data = emptyRadarData, - modifier = Modifier.size(200.dp), - ) + modifier = modifier, + ) { sample -> + val data = when (sample) { + ChartSample.DEFAULT, + ChartSample.EXPANDED, + ChartSample.COMPACT -> normalRadarData + ChartSample.EXTREME -> extremeRadarData + ChartSample.INVALID -> invalidRadarData + ChartSample.EMPTY -> emptyRadarData } + RadarChart( + data = data, + modifier = Modifier.size(sample.chartDimension), + accessibilityLabel = "Radar chart preview", + ) } } diff --git a/app/src/main/java/com/inseong/composechart/ui/theme/Color.kt b/app/src/main/java/com/inseong/composechart/ui/theme/Color.kt index 44b1485..a72f296 100644 --- a/app/src/main/java/com/inseong/composechart/ui/theme/Color.kt +++ b/app/src/main/java/com/inseong/composechart/ui/theme/Color.kt @@ -1,11 +1,22 @@ package com.inseong.composechart.ui.theme import androidx.compose.ui.graphics.Color +import com.inseong.composechart.ChartDefaults -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +internal val ChartBlue = ChartDefaults.colors[0] +internal val ChartGreen = ChartDefaults.colors[1] +internal val ChartTeal = ChartDefaults.colors[5] -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) +internal val LightBackground = Color(0xFFF7F9FC) +internal val LightSurface = Color(0xFFFFFFFF) +internal val LightSurfaceVariant = Color(0xFFEEF3F8) +internal val LightOnSurface = Color(0xFF18212B) +internal val LightOnSurfaceVariant = Color(0xFF586574) +internal val LightOutline = Color(0xFFD6DEE7) + +internal val DarkBackground = Color(0xFF0F141B) +internal val DarkSurface = Color(0xFF171D25) +internal val DarkSurfaceVariant = Color(0xFF222B36) +internal val DarkOnSurface = Color(0xFFE7EDF4) +internal val DarkOnSurfaceVariant = Color(0xFFAEBAC8) +internal val DarkOutline = Color(0xFF354251) diff --git a/app/src/main/java/com/inseong/composechart/ui/theme/Theme.kt b/app/src/main/java/com/inseong/composechart/ui/theme/Theme.kt index 52269b5..3005de1 100644 --- a/app/src/main/java/com/inseong/composechart/ui/theme/Theme.kt +++ b/app/src/main/java/com/inseong/composechart/ui/theme/Theme.kt @@ -1,34 +1,78 @@ package com.inseong.composechart.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 + primary = Color(0xFF8BBCFF), + onPrimary = Color(0xFF00315F), + primaryContainer = Color(0xFF164B7F), + onPrimaryContainer = Color(0xFFD5E7FF), + secondary = Color(0xFF69D6CB), + onSecondary = Color(0xFF003733), + secondaryContainer = Color(0xFF18514C), + onSecondaryContainer = Color(0xFFB7F0EA), + tertiary = Color(0xFF7DDC8C), + onTertiary = Color(0xFF00390D), + tertiaryContainer = Color(0xFF245130), + onTertiaryContainer = Color(0xFFC8F3D0), + background = DarkBackground, + onBackground = DarkOnSurface, + surface = DarkSurface, + onSurface = DarkOnSurface, + surfaceVariant = DarkSurfaceVariant, + onSurfaceVariant = DarkOnSurfaceVariant, + outline = DarkOutline, + outlineVariant = DarkOutline, ) private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 + primary = ChartBlue, + onPrimary = Color.White, + primaryContainer = Color(0xFFD9E9FF), + onPrimaryContainer = Color(0xFF0A315E), + secondary = ChartTeal, + onSecondary = Color.White, + secondaryContainer = Color(0xFFD7F5F2), + onSecondaryContainer = Color(0xFF073B38), + tertiary = ChartGreen, + onTertiary = Color.White, + tertiaryContainer = Color(0xFFDDF5E4), + onTertiaryContainer = Color(0xFF143B20), + background = LightBackground, + onBackground = LightOnSurface, + surface = LightSurface, + onSurface = LightOnSurface, + surfaceVariant = LightSurfaceVariant, + onSurfaceVariant = LightOnSurfaceVariant, + outline = LightOutline, + outlineVariant = LightOutline, +) + +private val AppShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(22.dp), + extraLarge = RoundedCornerShape(28.dp), ) @Composable fun ComposeChartTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit + dynamicColor: Boolean = false, + content: @Composable () -> Unit, ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { @@ -43,6 +87,7 @@ fun ComposeChartTheme( MaterialTheme( colorScheme = colorScheme, typography = Typography, - content = content + shapes = AppShapes, + content = content, ) } diff --git a/app/src/main/java/com/inseong/composechart/ui/theme/Type.kt b/app/src/main/java/com/inseong/composechart/ui/theme/Type.kt index 218289e..ed4f839 100644 --- a/app/src/main/java/com/inseong/composechart/ui/theme/Type.kt +++ b/app/src/main/java/com/inseong/composechart/ui/theme/Type.kt @@ -7,11 +7,46 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp val Typography = Typography( + displaySmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 42.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + ), bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + labelMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp, + ), ) diff --git a/compose-chart/build.gradle.kts b/compose-chart/build.gradle.kts index fe26aa6..3f8a1bd 100644 --- a/compose-chart/build.gradle.kts +++ b/compose-chart/build.gradle.kts @@ -55,7 +55,7 @@ mavenPublishing { publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) signAllPublications() - coordinates("io.github.ois0886", "compose-chart", "1.3.1") + coordinates("io.github.ois0886", "compose-chart", "1.3.2") pom { name.set("Compose Chart") diff --git a/compose-chart/src/main/java/com/inseong/composechart/ChartDefaults.kt b/compose-chart/src/main/java/com/inseong/composechart/ChartDefaults.kt index ecd960d..82cd0aa 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/ChartDefaults.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/ChartDefaults.kt @@ -36,6 +36,13 @@ object ChartDefaults { Color(0xFF38B2AC), // Teal ) + /** + * Keeps chart palette indexing safe when a caller supplies an empty custom palette. + */ + internal fun resolveColors(colors: List): List { + return colors.ifEmpty { this.colors } + } + // ── Light/Dark theme default colors ── /** Grid line color (light) */ diff --git a/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt b/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt index 44d1d4d..a30c315 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/bar/BarChart.kt @@ -97,7 +97,7 @@ private class BarEntryLayout( * @param modifier Layout Modifier. A finite size must be provided — the chart will not size itself. * @param style Chart style (orientation, bar corner radius, grouping, grid, axis, tooltip, * animation duration, highlight alpha). - * @param colors Palette for bar segments. See [ChartDefaults.colors] for the default palette. + * @param colors Palette for bar segments. An empty list falls back to [ChartDefaults.colors]. * @param zoomState Optional zoom/pan state. Pass [com.inseong.composechart.rememberChartZoomState] * to enable pinch-to-zoom and pan gestures. * @param accessibilityLabel Prefix used in the chart's semantics `contentDescription`. Default @@ -125,6 +125,8 @@ fun BarChart( onSelectionChanged: ((ChartSelection.Bar) -> Unit)? = null, onBarSelected: ((groupIndex: Int, entryIndex: Int, stackIndex: Int) -> Unit)? = null, ) { + val resolvedColors = remember(colors) { ChartDefaults.resolveColors(colors) } + // Detect dark theme and resolve styles val isDark = isSystemInDarkTheme() val resolvedGridStyle = style.grid.copy( @@ -204,7 +206,14 @@ fun BarChart( BarMath.calculateGroupWidth(chartArea.width, groupCount, groupSpacingPx) } val groupLabels = remember(validGroups) { validGroups.map { it.label } } - val barLayouts = remember(validGroups, chartArea, groupWidth, groupSpacingPx, barSpacingPx, colors) { + val barLayouts = remember( + validGroups, + chartArea, + groupWidth, + groupSpacingPx, + barSpacingPx, + resolvedColors, + ) { if (chartArea.width <= 0f || chartArea.height <= 0f) { emptyList() } else { @@ -222,9 +231,9 @@ fun BarChart( entryIndex = entryIndex, entry = entry, barLeft = groupLeft + entryIndex * (barWidth + barSpacingPx), - segmentColors = entry.colors.ifEmpty { colors }, + segmentColors = entry.colors.ifEmpty { resolvedColors }, totalValue = entry.safeTotal, - tooltipLineColor = colors[entryIndex % colors.size], + tooltipLineColor = resolvedColors[entryIndex % resolvedColors.size], ) }, ) diff --git a/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt b/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt index ec8fdab..7d40548 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/donut/DonutChart.kt @@ -69,7 +69,8 @@ private data class DonutGeometry( * @param modifier Layout Modifier. A finite size must be provided — the chart will not size itself. * @param style Chart style (hole radius, slice spacing, selected scale, labels, animation, start * angle). Use `holeRadius = 0f` for a filled pie look, `0.6f` for a typical donut. - * @param colors Slice color palette used when slices omit a color. See [ChartDefaults.colors]. + * @param colors Slice color palette used when slices omit a color. An empty list falls back to + * [ChartDefaults.colors]. * @param accessibilityLabel Base prefix used in the chart's semantics `contentDescription`. * Default `"도넛 차트"`; override when localizing or using a more specific label. * @param onClickLabel Screen reader click action label. When non-null, an onClick semantics node @@ -94,6 +95,8 @@ fun DonutChart( onSelectionChanged: ((ChartSelection.Donut) -> Unit)? = null, onSliceSelected: ((index: Int, slice: DonutSlice) -> Unit)? = null, ) { + val resolvedColors = remember(colors) { ChartDefaults.resolveColors(colors) } + val density = LocalDensity.current var chartSize by remember { mutableStateOf(IntSize.Zero) } val progress by rememberChartAnimation(style.animationDurationMs, animationKey = data) @@ -212,7 +215,7 @@ fun DonutChart( val actualSweep = (sweepAngle - spacingAngle).coerceAtLeast(0.1f) val sliceColor = if (slice.color == Color.Unspecified) { - colors[index % colors.size] + resolvedColors[index % resolvedColors.size] } else { slice.color } diff --git a/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt b/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt index 89ba2a0..c6b119f 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/line/LineChart.kt @@ -89,7 +89,7 @@ private class LineSeriesLayout( * @param modifier Layout Modifier. A finite size must be provided — the chart will not size itself. * @param style Chart style (curve mode, dots, grid, axis, tooltip, animation duration). * @param colors Palette used for series without an explicit color. Cycles when more series than - * colors. See [ChartDefaults.colors] for the default palette. + * colors. An empty list falls back to [ChartDefaults.colors]. * @param zoomState Optional zoom/pan state. Pass [com.inseong.composechart.rememberChartZoomState] * to enable pinch-to-zoom and pan gestures. * @param accessibilityLabel Prefix used in the chart's semantics `contentDescription`. Default @@ -117,6 +117,8 @@ fun LineChart( onSelectionChanged: ((ChartSelection.Line) -> Unit)? = null, onPointSelected: ((seriesIndex: Int, pointIndex: Int, point: ChartPoint) -> Unit)? = null, ) { + val resolvedColors = remember(colors) { ChartDefaults.resolveColors(colors) } + // Detect dark theme and resolve styles val isDark = isSystemInDarkTheme() val resolvedGridStyle = style.grid.copy( @@ -189,13 +191,13 @@ fun LineChart( val zOffsetX = zoomState?.offsetX ?: 0f val zOffsetY = zoomState?.offsetY ?: 0f - val lineLayouts = remember(validSeries, xyRange, chartArea, style.curved, colors) { + val lineLayouts = remember(validSeries, xyRange, chartArea, style.curved, resolvedColors) { if (chartArea.width <= 0f || chartArea.height <= 0f) { emptyList() } else { validSeries.mapIndexed { seriesIndex, series -> val seriesColor = if (series.color == Color.Unspecified) { - colors[seriesIndex % colors.size] + resolvedColors[seriesIndex % resolvedColors.size] } else { series.color } diff --git a/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt b/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt index 5a39837..4272c2b 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/pie/PieChart.kt @@ -33,7 +33,8 @@ import com.inseong.composechart.style.PieChartStyle * @param data Data to display (reuses [DonutChartData]). Non-positive slices are filtered out. * @param modifier Layout Modifier. A finite size must be provided — the chart will not size itself. * @param style Pie style (slice spacing, selected scale, labels, animation duration, start angle). - * @param colors Slice palette used when slices omit a color. See [ChartDefaults.colors]. + * @param colors Slice palette used when slices omit a color. An empty list falls back to + * [ChartDefaults.colors]. * @param accessibilityLabel Prefix used in the chart's semantics `contentDescription`. Default * `"파이 차트"`; override when localizing or using a more specific label. * @param onClickLabel Screen reader click action label. When non-null, an onClick semantics node diff --git a/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt b/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt index 86188ad..3b5e2f0 100644 --- a/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt +++ b/compose-chart/src/main/java/com/inseong/composechart/radar/RadarChart.kt @@ -74,7 +74,7 @@ private class RadarEntryLayout( * @param style Chart style (web levels, web line color/width, dot radius, fill alpha, label size, * animation duration). * @param colors Palette used for entries without an explicit color. Cycles after the palette is - * exhausted. See [ChartDefaults.colors]. + * exhausted. An empty list falls back to [ChartDefaults.colors]. * @param accessibilityLabel Prefix used in the chart's semantics `contentDescription`. Default * `"레이더 차트"`; override when localizing or using a more specific label. * @param onClickLabel Screen reader click action label. When non-null, an onClick semantics node @@ -98,6 +98,8 @@ fun RadarChart( onSelectionChanged: ((ChartSelection.Radar) -> Unit)? = null, onAxisSelected: ((axisIndex: Int) -> Unit)? = null, ) { + val resolvedColors = remember(colors) { ChartDefaults.resolveColors(colors) } + val isDark = isSystemInDarkTheme() val resolvedWebColor = ChartDefaults.resolveRadarWebColor(style.webLineColor, isDark) val resolvedLabelColor = ChartDefaults.resolveAxisLabelColor(style.labelColor, isDark) @@ -203,10 +205,10 @@ fun RadarChart( } } - val entryLayouts = remember(validEntries, resolvedMaxValue, axisCount, colors) { + val entryLayouts = remember(validEntries, resolvedMaxValue, axisCount, resolvedColors) { validEntries.mapIndexed { entryIndex, entry -> val entryColor = if (entry.color == Color.Unspecified) { - colors[entryIndex % colors.size] + resolvedColors[entryIndex % resolvedColors.size] } else { entry.color } diff --git a/compose-chart/src/test/java/com/inseong/composechart/ChartDefaultsTest.kt b/compose-chart/src/test/java/com/inseong/composechart/ChartDefaultsTest.kt new file mode 100644 index 0000000..1b86109 --- /dev/null +++ b/compose-chart/src/test/java/com/inseong/composechart/ChartDefaultsTest.kt @@ -0,0 +1,24 @@ +package com.inseong.composechart + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertSame +import org.junit.Test + +class ChartDefaultsTest { + + @Test + fun resolveColors_emptyPalette_returnsDefaultPalette() { + val resolved = ChartDefaults.resolveColors(emptyList()) + + assertSame(ChartDefaults.colors, resolved) + } + + @Test + fun resolveColors_customPalette_returnsSamePalette() { + val customColors = listOf(Color.Magenta, Color.Cyan) + + val resolved = ChartDefaults.resolveColors(customColors) + + assertSame(customColors, resolved) + } +} diff --git a/docs/project-architecture.md b/docs/project-architecture.md index 01ea61c..6e1c84d 100644 --- a/docs/project-architecture.md +++ b/docs/project-architecture.md @@ -106,6 +106,18 @@ Common commands: > AGP 9 note: the aggregate `test` task no longer accepts `--tests`. Use the > variant-specific `testDebugUnitTest` task when filtering to a single class. +README chart screenshots are generated from the real sample data and chart +composables on a connected emulator. The opt-in test does not write files during +normal instrumentation runs. To refresh all six light and six dark images, run: + +```bash +./scripts/update-readme-screenshots.sh +``` + +The script switches the emulator system theme, disables chart animations for +deterministic captures, and writes the 12 equally sized PNG files under +`screenshots/` using lowercase kebab-case names. + Release command: ```bash @@ -116,7 +128,7 @@ Release documentation checklist: - Keep `compose-chart/build.gradle.kts` Maven coordinates, README dependency snippets, README feature version heading, and `CHANGELOG.md` aligned to the same release version. - Move completed `CHANGELOG.md` entries from `Unreleased` into a dated version section before creating a release. -- Publish through a GitHub Release tag such as `v1.3.1`; `.github/workflows/publish.yml` runs tests, lint, and Maven Central publishing from that release event. +- Publish through a GitHub Release tag such as `v1.3.2`; `.github/workflows/publish.yml` runs tests, lint, and Maven Central publishing from that release event. - After publishing, confirm the GitHub Release is non-draft/non-prerelease unless intentionally shipping a prerelease, and verify the publish workflow completed successfully. GitHub Actions: diff --git a/screenshots/Bar Chart.png b/screenshots/Bar Chart.png deleted file mode 100644 index 6e99102..0000000 Binary files a/screenshots/Bar Chart.png and /dev/null differ diff --git a/screenshots/Donut Chart.png b/screenshots/Donut Chart.png deleted file mode 100644 index 498d627..0000000 Binary files a/screenshots/Donut Chart.png and /dev/null differ diff --git a/screenshots/Gauge Chart.png b/screenshots/Gauge Chart.png deleted file mode 100644 index 08cc326..0000000 Binary files a/screenshots/Gauge Chart.png and /dev/null differ diff --git a/screenshots/Line Chart.png b/screenshots/Line Chart.png deleted file mode 100644 index 27655ad..0000000 Binary files a/screenshots/Line Chart.png and /dev/null differ diff --git a/screenshots/Pie Chart.png b/screenshots/Pie Chart.png deleted file mode 100644 index 2d8ad3f..0000000 Binary files a/screenshots/Pie Chart.png and /dev/null differ diff --git a/screenshots/Radar Chart.png b/screenshots/Radar Chart.png deleted file mode 100644 index c61b8c7..0000000 Binary files a/screenshots/Radar Chart.png and /dev/null differ diff --git a/screenshots/bar-chart-dark.png b/screenshots/bar-chart-dark.png new file mode 100644 index 0000000..66aa840 Binary files /dev/null and b/screenshots/bar-chart-dark.png differ diff --git a/screenshots/bar-chart-light.png b/screenshots/bar-chart-light.png new file mode 100644 index 0000000..42d3da3 Binary files /dev/null and b/screenshots/bar-chart-light.png differ diff --git a/screenshots/donut-chart-dark.png b/screenshots/donut-chart-dark.png new file mode 100644 index 0000000..f167575 Binary files /dev/null and b/screenshots/donut-chart-dark.png differ diff --git a/screenshots/donut-chart-light.png b/screenshots/donut-chart-light.png new file mode 100644 index 0000000..0cd1bfd Binary files /dev/null and b/screenshots/donut-chart-light.png differ diff --git a/screenshots/gauge-chart-dark.png b/screenshots/gauge-chart-dark.png new file mode 100644 index 0000000..2813529 Binary files /dev/null and b/screenshots/gauge-chart-dark.png differ diff --git a/screenshots/gauge-chart-light.png b/screenshots/gauge-chart-light.png new file mode 100644 index 0000000..0eac31a Binary files /dev/null and b/screenshots/gauge-chart-light.png differ diff --git a/screenshots/line-chart-dark.png b/screenshots/line-chart-dark.png new file mode 100644 index 0000000..9100441 Binary files /dev/null and b/screenshots/line-chart-dark.png differ diff --git a/screenshots/line-chart-light.png b/screenshots/line-chart-light.png new file mode 100644 index 0000000..23c7f3d Binary files /dev/null and b/screenshots/line-chart-light.png differ diff --git a/screenshots/pie-chart-dark.png b/screenshots/pie-chart-dark.png new file mode 100644 index 0000000..bfd5824 Binary files /dev/null and b/screenshots/pie-chart-dark.png differ diff --git a/screenshots/pie-chart-light.png b/screenshots/pie-chart-light.png new file mode 100644 index 0000000..e0f767a Binary files /dev/null and b/screenshots/pie-chart-light.png differ diff --git a/screenshots/radar-chart-dark.png b/screenshots/radar-chart-dark.png new file mode 100644 index 0000000..750e7f5 Binary files /dev/null and b/screenshots/radar-chart-dark.png differ diff --git a/screenshots/radar-chart-light.png b/screenshots/radar-chart-light.png new file mode 100644 index 0000000..5e8cd2d Binary files /dev/null and b/screenshots/radar-chart-light.png differ diff --git a/scripts/update-readme-screenshots.sh b/scripts/update-readme-screenshots.sh new file mode 100755 index 0000000..a2cee99 --- /dev/null +++ b/scripts/update-readme-screenshots.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly PACKAGE_NAME="com.inseong.composechart.sample" +readonly TEST_RUNNER="$PACKAGE_NAME.test/androidx.test.runner.AndroidJUnitRunner" +readonly TEST_CLASS="com.inseong.composechart.ReadmeScreenshotTest" +readonly DEVICE_DIRECTORY="/sdcard/Android/data/$PACKAGE_NAME/files/readme-screenshots" +readonly SCREENSHOT_DIRECTORY="$ROOT_DIR/screenshots" +readonly APP_APK="$ROOT_DIR/app/build/outputs/apk/debug/app-debug.apk" +readonly TEST_APK="$ROOT_DIR/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" +readonly CHARTS=(line bar donut gauge radar pie) +readonly THEMES=(light dark) + +if ! command -v adb >/dev/null 2>&1; then + echo "adb를 찾을 수 없습니다. Android SDK platform-tools를 PATH에 추가하세요." >&2 + exit 1 +fi + +adb_command=(adb) +if [[ -n "${ANDROID_SERIAL:-}" ]]; then + adb_command+=( -s "$ANDROID_SERIAL" ) +else + device_count="$(adb devices | awk 'NR > 1 && $2 == "device" { count++ } END { print count + 0 }')" + if [[ "$device_count" -ne 1 ]]; then + echo "연결된 Android 기기가 정확히 1대여야 합니다. 현재: $device_count" >&2 + echo "여러 기기를 사용할 때는 ANDROID_SERIAL을 지정하세요." >&2 + exit 1 + fi +fi + +staging_directory="$(mktemp -d)" +restore_theme() { + "${adb_command[@]}" shell cmd uimode night auto >/dev/null 2>&1 || true + rm -rf "$staging_directory" +} +trap restore_theme EXIT + +cd "$ROOT_DIR" +./gradlew :app:assembleDebug :app:assembleDebugAndroidTest +"${adb_command[@]}" install -r "$APP_APK" +"${adb_command[@]}" install -r "$TEST_APK" +"${adb_command[@]}" shell rm -rf "$DEVICE_DIRECTORY" + +for theme in "${THEMES[@]}"; do + if [[ "$theme" == "dark" ]]; then + "${adb_command[@]}" shell cmd uimode night yes + else + "${adb_command[@]}" shell cmd uimode night no + fi + "${adb_command[@]}" shell am force-stop "$PACKAGE_NAME" + "${adb_command[@]}" shell am instrument -w \ + -e recordReadmeScreenshots true \ + -e screenshotTheme "$theme" \ + -e class "$TEST_CLASS" \ + "$TEST_RUNNER" +done + +"${adb_command[@]}" pull "$DEVICE_DIRECTORY/." "$staging_directory" +mkdir -p "$SCREENSHOT_DIRECTORY" + +for chart in "${CHARTS[@]}"; do + for theme in "${THEMES[@]}"; do + filename="$chart-chart-$theme.png" + source_file="$staging_directory/$filename" + if [[ ! -s "$source_file" ]]; then + echo "생성된 스크린샷을 찾을 수 없습니다: $filename" >&2 + exit 1 + fi + cp "$source_file" "$SCREENSHOT_DIRECTORY/$filename" + done +done + +echo "README 스크린샷 12장을 갱신했습니다: $SCREENSHOT_DIRECTORY"