From 06d746204abfb43b0bb7a914c66976e8d73a6c40 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 11 May 2026 17:43:27 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8(Repor?= =?UTF-8?q?t)=20=EA=B8=B0=EB=8A=A5=20=EB=AA=A8=EB=93=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 리포트 기능의 `api` 및 `impl` 모듈 생성** * `:feature:report:api`와 `:feature:report:impl` 모듈을 신규 생성하고 `settings.gradle.kts`에 등록했습니다. * `api` 모듈에는 외부 노출을 위한 `ReportNavKey`를, `impl` 모듈에는 실제 화면 구현 및 관련 로직을 배치했습니다. * **feat: 리포트 화면 및 ViewModel 기본 구조 구현** * MVI 패턴을 위한 `ReportUiState`, `ReportUiIntent`, `ReportUiEffect` 인터페이스를 정의했습니다. * `ReportViewModel`을 추가하여 초기 상태(`Loading`)에서 데이터 로드 완료(`Content`)로 전환되는 기본 로직을 작성했습니다. * Compose를 사용하여 `Loading`과 `Content` 상태에 대응하는 `ReportScreen` UI를 구현했습니다. * **feat: Navigation3 기반 내비게이션 연동** * `ReportNavKey`를 정의하여 타입 안정성이 보장되는 내비게이션 경로를 추가했습니다. * `ReportEntryBuilder`를 통해 `ActivityRetainedComponent` 수준에서 내비게이션 엔트리가 주입되도록 Hilt 모듈 설정을 완료했습니다. * **style: 관련 리소스 추가** * 리포트 화면에서 사용하는 로딩 및 타이틀 문자열 리소스를 추가했습니다. --- Prezel/feature/report/api/build.gradle.kts | 7 +++ Prezel/feature/report/api/consumer-rules.pro | 1 + Prezel/feature/report/api/proguard-rules.pro | 21 ++++++++ .../prezel/feature/report/api/ReportNavKey.kt | 7 +++ Prezel/feature/report/impl/build.gradle.kts | 11 ++++ Prezel/feature/report/impl/consumer-rules.pro | 1 + Prezel/feature/report/impl/proguard-rules.pro | 21 ++++++++ .../feature/report/impl/ReportScreen.kt | 53 +++++++++++++++++++ .../feature/report/impl/ReportViewModel.kt | 17 ++++++ .../report/impl/contract/ReportUiEffect.kt | 5 ++ .../report/impl/contract/ReportUiIntent.kt | 5 ++ .../report/impl/contract/ReportUiState.kt | 11 ++++ .../impl/navigation/ReportEntryBuilder.kt | 28 ++++++++++ .../impl/src/main/res/values/strings.xml | 5 ++ Prezel/settings.gradle.kts | 2 + 15 files changed, 195 insertions(+) create mode 100644 Prezel/feature/report/api/build.gradle.kts create mode 100644 Prezel/feature/report/api/consumer-rules.pro create mode 100644 Prezel/feature/report/api/proguard-rules.pro create mode 100644 Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt create mode 100644 Prezel/feature/report/impl/build.gradle.kts create mode 100644 Prezel/feature/report/impl/consumer-rules.pro create mode 100644 Prezel/feature/report/impl/proguard-rules.pro create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportScreen.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportViewModel.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiEffect.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiIntent.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiState.kt create mode 100644 Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt create mode 100644 Prezel/feature/report/impl/src/main/res/values/strings.xml diff --git a/Prezel/feature/report/api/build.gradle.kts b/Prezel/feature/report/api/build.gradle.kts new file mode 100644 index 00000000..c5df1dbe --- /dev/null +++ b/Prezel/feature/report/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.android.feature.api) +} + +android { + namespace = "com.team.prezel.feature.report.api" +} diff --git a/Prezel/feature/report/api/consumer-rules.pro b/Prezel/feature/report/api/consumer-rules.pro new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Prezel/feature/report/api/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/Prezel/feature/report/api/proguard-rules.pro b/Prezel/feature/report/api/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/report/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt new file mode 100644 index 00000000..b3115120 --- /dev/null +++ b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.report.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object ReportNavKey : NavKey diff --git a/Prezel/feature/report/impl/build.gradle.kts b/Prezel/feature/report/impl/build.gradle.kts new file mode 100644 index 00000000..37ffa7cf --- /dev/null +++ b/Prezel/feature/report/impl/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.report.impl" +} + +dependencies { + implementation(projects.featureReportApi) +} diff --git a/Prezel/feature/report/impl/consumer-rules.pro b/Prezel/feature/report/impl/consumer-rules.pro new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/Prezel/feature/report/impl/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/Prezel/feature/report/impl/proguard-rules.pro b/Prezel/feature/report/impl/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/report/impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportScreen.kt new file mode 100644 index 00000000..cf2facf1 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportScreen.kt @@ -0,0 +1,53 @@ +package com.team.prezel.feature.report.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.report.impl.contract.ReportUiState + +@Composable +internal fun ReportScreen( + modifier: Modifier = Modifier, + viewModel: ReportViewModel = hiltViewModel(), +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + ReportScreen( + uiState = uiState.value, + modifier = modifier, + ) +} + +@Composable +private fun ReportScreen( + uiState: ReportUiState, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when (uiState) { + ReportUiState.Loading -> Text(text = stringResource(R.string.feature_report_impl_loading)) + ReportUiState.Content -> Text(text = stringResource(R.string.feature_report_impl_title)) + } + } +} + +@BasicPreview +@Composable +private fun ReportScreenPreview() { + PrezelTheme { + ReportScreen( + uiState = ReportUiState.Content, + ) + } +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportViewModel.kt new file mode 100644 index 00000000..49546280 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/ReportViewModel.kt @@ -0,0 +1,17 @@ +package com.team.prezel.feature.report.impl + +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.report.impl.contract.ReportUiEffect +import com.team.prezel.feature.report.impl.contract.ReportUiIntent +import com.team.prezel.feature.report.impl.contract.ReportUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class ReportViewModel @Inject constructor() : BaseViewModel(ReportUiState.Loading) { + init { + updateState { ReportUiState.Content } + } + + override fun onIntent(intent: ReportUiIntent) = Unit +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiEffect.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiEffect.kt new file mode 100644 index 00000000..59e6d906 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiEffect.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.report.impl.contract + +import com.team.prezel.core.ui.base.UiEffect + +internal sealed interface ReportUiEffect : UiEffect diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiIntent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiIntent.kt new file mode 100644 index 00000000..09380581 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiIntent.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.report.impl.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface ReportUiIntent : UiIntent diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiState.kt new file mode 100644 index 00000000..8b92d28c --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/ReportUiState.kt @@ -0,0 +1,11 @@ +package com.team.prezel.feature.report.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.base.UiState + +@Immutable +internal sealed interface ReportUiState : UiState { + data object Loading : ReportUiState + + data object Content : ReportUiState +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt new file mode 100644 index 00000000..6e438663 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt @@ -0,0 +1,28 @@ +package com.team.prezel.feature.report.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.team.prezel.feature.report.api.ReportNavKey +import com.team.prezel.feature.report.impl.ReportScreen +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +internal fun EntryProviderScope.featureReportEntryBuilder() { + entry { + ReportScreen() + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeatureReportModule { + @IntoSet + @Provides + fun provideFeatureReportEntryBuilder(): EntryProviderScope.() -> Unit = + { + featureReportEntryBuilder() + } +} diff --git a/Prezel/feature/report/impl/src/main/res/values/strings.xml b/Prezel/feature/report/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..ea474d21 --- /dev/null +++ b/Prezel/feature/report/impl/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + 리포트 화면을 준비하고 있어요. + 리포트 화면 + diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 825400ea..dbf570f2 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -59,6 +59,8 @@ includeAuto( ":feature:setting:impl", ":feature:profile:api", ":feature:profile:impl", + ":feature:report:api", + ":feature:report:impl", ":feature:login:api", ":feature:login:impl", ":feature:terms:api", From fc438ccd17a6ffe9cda2e98707722d221a539dbd Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 11 May 2026 20:19:27 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=EC=86=8D=EB=8F=84=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=ED=99=94=EB=A5=BC=20=EC=9C=84=ED=95=9C=20SpeedGraph?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `SpeedGraph` 컴포넌트 신규 구현** * 사용자의 속도(SPM)를 원형 게이지 형태로 시각화하는 `SpeedGraph` 컴포넌트를 추가했습니다. * 전체 범위를 나타내는 `baseRange`와 권장 범위를 나타내는 `goodRange`를 설정할 수 있습니다. * 유저의 현재 수치가 권장 범위 내에 있는지 여부에 따라 게이지 색상이 동적으로 변경되도록 구현했습니다. * **feat: 그래프 구성을 위한 데이터 모델 및 UI 요소 추가** * 그래프의 배경, 강조 범위, 게이지 색상을 설정할 수 있는 `SpeedGraphColors` 데이터 클래스를 정의했습니다. * `Canvas`를 사용하여 아크(Arc) 형태의 트랙과 게이지를 드로잉하고, 하단에 최소/최대 수치 라벨을 배치했습니다. * 그래프 중앙에 현재 SPM 수치와 단위를 표시하는 `SpmDisplay`를 추가했습니다. * **refactor: 각도 계산 및 범위 처리를 위한 유틸리티 함수 구현** * 수치를 그래프 상의 각도로 변환하는 `toAngle`, `sweepFromStart` 등의 확장 함수를 추가했습니다. * `IntRange` 간의 교집합을 계산하는 `intersect` 및 스윕 각도 계산 로직을 포함했습니다. --- .../core/ui/component/graph/SpeedGraph.kt | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt new file mode 100644 index 00000000..d980836f --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt @@ -0,0 +1,252 @@ +package com.team.prezel.core.ui.component.graph + +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.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.layout.size +import androidx.compose.material3.ProvideTextStyle +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.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlin.math.max + +private const val DEFAULT_LOWER_BOUND = 180 +private const val DEFAULT_UPPER_BOUND = 280 +private const val GOOD_LOWER_BOUND = 210 +private const val GOOD_UPPER_BOUND = 260 + +private const val FULL_CIRCLE_DEGREES = 270f +private const val GAP_CENTER_ANGLE = 90f +private const val GRAPH_STROKE_RATIO = 0.12f +private val GRAPH_SIZE = 160.dp +private val GRAPH_DIAMETER = 152.dp +private val GRAPH_LABEL_HORIZONTAL_PADDING = 30.dp + +data class SpeedGraphColors( + val baseTrackColor: Color, + val goodRangeColor: Color, + val goodGaugeColor: Color, + val badGaugeColor: Color, +) { + companion object { + @Composable + fun getDefault( + baseTrackColor: Color = PrezelTheme.colors.bgMedium, + goodRangeColor: Color = PrezelTheme.colors.bgLarge, + goodGaugeColor: Color = PrezelTheme.colors.interactiveRegular, + badGaugeColor: Color = PrezelTheme.colors.feedbackWarningRegular, + ): SpeedGraphColors = + SpeedGraphColors( + baseTrackColor = baseTrackColor, + goodRangeColor = goodRangeColor, + goodGaugeColor = goodGaugeColor, + badGaugeColor = badGaugeColor, + ) + } +} + +/** + * 사용자의 속도를 시각화하는 컴포넌트입니다. + * + * @param userGauge 유저의 속도를 보여주는 영역 + * @param goodRange 적절한 속도의 범주를 보여주는 영역 + * @param baseRange 그래프 기준 영역 + */ +@Composable +fun SpeedGraph( + userGauge: Int, + modifier: Modifier = Modifier, + goodRange: IntRange = IntRange(GOOD_LOWER_BOUND, GOOD_UPPER_BOUND), + baseRange: IntRange = IntRange(DEFAULT_LOWER_BOUND, DEFAULT_UPPER_BOUND), + colors: SpeedGraphColors = SpeedGraphColors.getDefault(), +) { + Box( + modifier = modifier + .size(GRAPH_SIZE) + .padding(horizontal = PrezelTheme.spacing.V4) + .padding(top = PrezelTheme.spacing.V8), + ) { + SpeedGraphChart( + userGauge = userGauge, + goodRange = goodRange, + baseRange = baseRange, + colors = colors, + ) + + RangeBounds( + lowerBound = baseRange.first, + upperBound = baseRange.last, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +@Composable +private fun SpeedGraphChart( + userGauge: Int, + goodRange: IntRange, + baseRange: IntRange, + colors: SpeedGraphColors, + modifier: Modifier = Modifier, +) { + val hasValidBaseRange = baseRange.last > baseRange.first + val clampedGauge = userGauge.coerceIn(baseRange.first, baseRange.last) + val clampedGoodRange = goodRange.intersect(baseRange) + val density = LocalDensity.current + val startAngle = calculateStartAngle() + val sweepAngle = FULL_CIRCLE_DEGREES + val graphWidthPx = with(density) { GRAPH_DIAMETER.toPx() } + val strokeWidthPx = (graphWidthPx / 2f) * GRAPH_STROKE_RATIO + val arcDiameter = graphWidthPx - strokeWidthPx + val arcTopLeft = Offset( + x = (graphWidthPx - arcDiameter) / 2f, + y = strokeWidthPx / 2f, + ) + val arcSize = Size(width = arcDiameter, height = arcDiameter) + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawArc( + color = colors.baseTrackColor, + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + ) + + if (hasValidBaseRange && !clampedGoodRange.isEmpty()) { + drawArc( + color = colors.goodRangeColor, + startAngle = clampedGoodRange.first.toAngle(baseRange, startAngle, sweepAngle), + sweepAngle = clampedGoodRange.sweepAngle(baseRange), + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + ) + } + + if (hasValidBaseRange) { + drawArc( + color = if (userGauge in goodRange) colors.goodGaugeColor else colors.badGaugeColor, + startAngle = startAngle, + sweepAngle = clampedGauge.sweepFromStart(baseRange), + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + ) + } + } + + SpmDisplay(userGauge = userGauge) + } +} + +@Composable +private fun SpmDisplay(userGauge: Int) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = userGauge.toString(), + modifier = Modifier.height(32.dp), + style = PrezelTheme.typography.title1Bold, + color = PrezelTheme.colors.textRegular, + ) + + Text( + text = "spm", + modifier = Modifier.height(18.dp), + style = PrezelTheme.typography.caption1Regular, + color = PrezelTheme.colors.textSmall, + ) + } +} + +@Composable +private fun RangeBounds( + lowerBound: Int, + upperBound: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = GRAPH_LABEL_HORIZONTAL_PADDING), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ProvideTextStyle( + value = PrezelTheme.typography.caption1Regular.copy(color = PrezelTheme.colors.textSmall), + ) { + Text(text = lowerBound.toString()) + Text(text = upperBound.toString()) + } + } +} + +@BasicPreview +@Composable +private fun SpeedGraphPreview() { + PrezelTheme { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SpeedGraph(userGauge = 190) + SpeedGraph(userGauge = 220) + SpeedGraph(userGauge = 270) + } + } +} + +private fun Int.toAngle( + baseRange: IntRange, + startAngle: Float = calculateStartAngle(), + sweepAngle: Float = FULL_CIRCLE_DEGREES, +): Float { + if (baseRange.last <= baseRange.first) return startAngle + + val progress = (this - baseRange.first).toFloat() / (baseRange.last - baseRange.first).toFloat() + return startAngle + (sweepAngle * progress.coerceIn(0f, 1f)) +} + +private fun Int.sweepFromStart(baseRange: IntRange): Float { + val startAngle = calculateStartAngle() + return toAngle(baseRange, startAngle = startAngle) - startAngle +} + +private fun IntRange.sweepAngle(baseRange: IntRange): Float { + if (isEmpty()) return 0f + return max(last.toAngle(baseRange) - first.toAngle(baseRange), 0f) +} + +private fun IntRange.intersect(other: IntRange): IntRange { + val start = max(first, other.first) + val endInclusive = minOf(last, other.last) + return if (start <= endInclusive) IntRange(start, endInclusive) else IntRange.EMPTY +} + +private fun calculateStartAngle(): Float { + val gapSweep = 360f - FULL_CIRCLE_DEGREES + return GAP_CENTER_ANGLE + (gapSweep / 2f) +} From 8110ef0b950974f3c842f3e56c0e31b254930cfc Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 11 May 2026 20:37:07 +0900 Subject: [PATCH 03/19] =?UTF-8?q?refactor:=20SpeedGraph=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: SpeedGraph 레이아웃 유연성 확보 및 하드코딩된 수치 제거** * `BoxWithConstraints`를 도입하여 부모 컨테이너의 너비(`maxWidth`)에 따라 하단 레이블의 패딩이 비율(`GRAPH_LABEL_HORIZONTAL_PADDING_RATIO`)에 맞춰 동적으로 조절되도록 수정했습니다. * `SpeedGraphArc` 내부에서 하드코딩된 크기(`GRAPH_DIAMETER`) 대신 `Canvas`의 `size`를 기준으로 원의 직경과 위치를 계산하도록 변경하여 다양한 컴포넌트 크기에 대응할 수 있도록 했습니다. * **refactor: 각도 계산 로직 및 관련 함수 단순화** * 복잡한 계산식 대신 고정된 `START_ANGLE`(135도) 상수를 정의하여 시작 각도 관리 방식을 직관적으로 변경했습니다. * `toAngle`, `sweepFromStart`, `sweepAngle` 등의 헬퍼 함수에서 불필요한 매개변수를 제거하고, `START_ANGLE`과 `FULL_CIRCLE_DEGREES`를 기반으로 로직을 일원화했습니다. * `LocalDensity` 참조를 제거하고 `Canvas`의 `DrawScope` 내에서 픽셀 기반 계산을 수행하도록 최적화했습니다. * **style: 상수 명명 규칙 및 프리뷰 코드 정리** * 기본 범위 값 관련 상수명에 `DEFAULT_` 접두사를 추가하여 의미를 명확히 했습니다. * `SpeedGraphPreview`에서 다양한 상태를 확인할 수 있도록 샘플 데이터를 업데이트했습니다. --- .../core/ui/component/graph/SpeedGraph.kt | 110 ++++++++---------- 1 file changed, 51 insertions(+), 59 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt index d980836f..ecf6824f 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt @@ -3,6 +3,7 @@ package com.team.prezel.core.ui.component.graph import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -20,23 +21,23 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import kotlin.math.max +import kotlin.math.min private const val DEFAULT_LOWER_BOUND = 180 private const val DEFAULT_UPPER_BOUND = 280 -private const val GOOD_LOWER_BOUND = 210 -private const val GOOD_UPPER_BOUND = 260 +private const val DEFAULT_GOOD_LOWER_BOUND = 210 +private const val DEFAULT_GOOD_UPPER_BOUND = 260 private const val FULL_CIRCLE_DEGREES = 270f -private const val GAP_CENTER_ANGLE = 90f +private const val START_ANGLE = 135f private const val GRAPH_STROKE_RATIO = 0.12f +private const val GRAPH_LABEL_HORIZONTAL_PADDING_RATIO = 0.1875f private val GRAPH_SIZE = 160.dp -private val GRAPH_DIAMETER = 152.dp -private val GRAPH_LABEL_HORIZONTAL_PADDING = 30.dp data class SpeedGraphColors( val baseTrackColor: Color, @@ -72,11 +73,11 @@ data class SpeedGraphColors( fun SpeedGraph( userGauge: Int, modifier: Modifier = Modifier, - goodRange: IntRange = IntRange(GOOD_LOWER_BOUND, GOOD_UPPER_BOUND), + goodRange: IntRange = IntRange(DEFAULT_GOOD_LOWER_BOUND, DEFAULT_GOOD_UPPER_BOUND), baseRange: IntRange = IntRange(DEFAULT_LOWER_BOUND, DEFAULT_UPPER_BOUND), colors: SpeedGraphColors = SpeedGraphColors.getDefault(), ) { - Box( + BoxWithConstraints( modifier = modifier .size(GRAPH_SIZE) .padding(horizontal = PrezelTheme.spacing.V4) @@ -93,6 +94,7 @@ fun SpeedGraph( lowerBound = baseRange.first, upperBound = baseRange.last, modifier = Modifier.align(Alignment.BottomCenter), + horizontalPadding = maxWidth * GRAPH_LABEL_HORIZONTAL_PADDING_RATIO, ) } } @@ -106,56 +108,54 @@ private fun SpeedGraphChart( modifier: Modifier = Modifier, ) { val hasValidBaseRange = baseRange.last > baseRange.first - val clampedGauge = userGauge.coerceIn(baseRange.first, baseRange.last) val clampedGoodRange = goodRange.intersect(baseRange) - val density = LocalDensity.current - val startAngle = calculateStartAngle() - val sweepAngle = FULL_CIRCLE_DEGREES - val graphWidthPx = with(density) { GRAPH_DIAMETER.toPx() } - val strokeWidthPx = (graphWidthPx / 2f) * GRAPH_STROKE_RATIO - val arcDiameter = graphWidthPx - strokeWidthPx - val arcTopLeft = Offset( - x = (graphWidthPx - arcDiameter) / 2f, - y = strokeWidthPx / 2f, - ) - val arcSize = Size(width = arcDiameter, height = arcDiameter) Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { + val graphWidthPx = min(size.width, size.height) + val strokeWidthPx = (graphWidthPx / 2f) * GRAPH_STROKE_RATIO + val arcDiameter = graphWidthPx - strokeWidthPx + val arcTopLeft = Offset( + x = (size.width - arcDiameter) / 2f, + y = (size.height - arcDiameter) / 2f, + ) + val arcSize = Size(width = arcDiameter, height = arcDiameter) + val arcStroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round) + drawArc( color = colors.baseTrackColor, - startAngle = startAngle, - sweepAngle = sweepAngle, + startAngle = START_ANGLE, + sweepAngle = FULL_CIRCLE_DEGREES, useCenter = false, topLeft = arcTopLeft, size = arcSize, - style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + style = arcStroke, ) if (hasValidBaseRange && !clampedGoodRange.isEmpty()) { drawArc( color = colors.goodRangeColor, - startAngle = clampedGoodRange.first.toAngle(baseRange, startAngle, sweepAngle), + startAngle = clampedGoodRange.first.toAngle(baseRange), sweepAngle = clampedGoodRange.sweepAngle(baseRange), useCenter = false, topLeft = arcTopLeft, size = arcSize, - style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + style = arcStroke, ) } if (hasValidBaseRange) { drawArc( color = if (userGauge in goodRange) colors.goodGaugeColor else colors.badGaugeColor, - startAngle = startAngle, - sweepAngle = clampedGauge.sweepFromStart(baseRange), + startAngle = START_ANGLE, + sweepAngle = userGauge.sweepFromStart(baseRange = baseRange), useCenter = false, topLeft = arcTopLeft, size = arcSize, - style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + style = arcStroke, ) } } @@ -187,12 +187,13 @@ private fun SpmDisplay(userGauge: Int) { private fun RangeBounds( lowerBound: Int, upperBound: Int, + horizontalPadding: Dp, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = GRAPH_LABEL_HORIZONTAL_PADDING), + .padding(horizontal = horizontalPadding), horizontalArrangement = Arrangement.SpaceBetween, ) { ProvideTextStyle( @@ -204,40 +205,21 @@ private fun RangeBounds( } } -@BasicPreview -@Composable -private fun SpeedGraphPreview() { - PrezelTheme { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - SpeedGraph(userGauge = 190) - SpeedGraph(userGauge = 220) - SpeedGraph(userGauge = 270) - } - } -} - -private fun Int.toAngle( - baseRange: IntRange, - startAngle: Float = calculateStartAngle(), - sweepAngle: Float = FULL_CIRCLE_DEGREES, -): Float { - if (baseRange.last <= baseRange.first) return startAngle +private fun Int.toAngle(baseRange: IntRange): Float { + if (baseRange.last <= baseRange.first) return START_ANGLE val progress = (this - baseRange.first).toFloat() / (baseRange.last - baseRange.first).toFloat() - return startAngle + (sweepAngle * progress.coerceIn(0f, 1f)) + return START_ANGLE + (FULL_CIRCLE_DEGREES * progress.coerceIn(0f, 1f)) } -private fun Int.sweepFromStart(baseRange: IntRange): Float { - val startAngle = calculateStartAngle() - return toAngle(baseRange, startAngle = startAngle) - startAngle -} +private fun Int.sweepFromStart(baseRange: IntRange): Float = toAngle(baseRange) - START_ANGLE private fun IntRange.sweepAngle(baseRange: IntRange): Float { if (isEmpty()) return 0f - return max(last.toAngle(baseRange) - first.toAngle(baseRange), 0f) + return max( + last.toAngle(baseRange) - first.toAngle(baseRange), + 0f, + ) } private fun IntRange.intersect(other: IntRange): IntRange { @@ -246,7 +228,17 @@ private fun IntRange.intersect(other: IntRange): IntRange { return if (start <= endInclusive) IntRange(start, endInclusive) else IntRange.EMPTY } -private fun calculateStartAngle(): Float { - val gapSweep = 360f - FULL_CIRCLE_DEGREES - return GAP_CENTER_ANGLE + (gapSweep / 2f) +@BasicPreview +@Composable +private fun SpeedGraphPreview() { + PrezelTheme { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SpeedGraph(userGauge = 241) + SpeedGraph(userGauge = 190) + SpeedGraph(userGauge = 270) + } + } } From 4e161228a78e9e14ce886b0b83435972946e8b88 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 11 May 2026 20:42:53 +0900 Subject: [PATCH 04/19] =?UTF-8?q?docs:=20SpeedGraph=20=EB=82=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=84=A4=EB=AA=85=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Preview=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **docs: SpeedGraph 내 주요 함수 및 유틸리티 메서드 KDoc 추가** * `toAngle`, `sweepAngle`, `intersect` 등 그래프 시각화를 위한 각도 및 범위 계산 로직에 설명을 추가하여 코드 이해도를 높였습니다. * `SpmDisplay`, `RangeBounds` 등 내부 컴포저블 함수의 역할을 명시했습니다. * **refactor: SpeedGraph Preview 구조 개선** * 기존 하나의 Preview 함수에서 여러 상태를 한꺼번에 보여주던 방식을 `SpeedGraphGoodPreview`, `SpeedGraphSlowPreview`, `SpeedGraphFastPreview`로 개별 분리했습니다. * 이를 통해 IDE 디자인 툴에서 각 속도 상태별(적정, 느림, 빠름) 그래프 모습을 개별적으로 확인하기 용이하도록 개선했습니다. --- .../core/ui/component/graph/SpeedGraph.kt | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt index ecf6824f..3b937134 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt @@ -164,6 +164,7 @@ private fun SpeedGraphChart( } } +/** 현재 속도 값과 단위를 그래프 중앙에 표시합니다. */ @Composable private fun SpmDisplay(userGauge: Int) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -183,6 +184,7 @@ private fun SpmDisplay(userGauge: Int) { } } +/** 기준 범위의 시작값과 끝값을 그래프 하단에 배치합니다. */ @Composable private fun RangeBounds( lowerBound: Int, @@ -205,6 +207,7 @@ private fun RangeBounds( } } +/** 기준 범위 안의 값을 그래프 각도로 변환합니다. */ private fun Int.toAngle(baseRange: IntRange): Float { if (baseRange.last <= baseRange.first) return START_ANGLE @@ -212,8 +215,10 @@ private fun Int.toAngle(baseRange: IntRange): Float { return START_ANGLE + (FULL_CIRCLE_DEGREES * progress.coerceIn(0f, 1f)) } +/** 그래프 시작점부터 현재 값까지의 sweep 각도를 계산합니다. */ private fun Int.sweepFromStart(baseRange: IntRange): Float = toAngle(baseRange) - START_ANGLE +/** 기준 범위 안에서 겹치는 구간의 sweep 각도를 계산합니다. */ private fun IntRange.sweepAngle(baseRange: IntRange): Float { if (isEmpty()) return 0f return max( @@ -222,6 +227,7 @@ private fun IntRange.sweepAngle(baseRange: IntRange): Float { ) } +/** 두 범위가 겹치는 구간만 남깁니다. */ private fun IntRange.intersect(other: IntRange): IntRange { val start = max(first, other.first) val endInclusive = minOf(last, other.last) @@ -230,15 +236,33 @@ private fun IntRange.intersect(other: IntRange): IntRange { @BasicPreview @Composable -private fun SpeedGraphPreview() { +private fun SpeedGraphGoodPreview() { PrezelTheme { - Column( + SpeedGraph( + userGauge = 241, modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - SpeedGraph(userGauge = 241) - SpeedGraph(userGauge = 190) - SpeedGraph(userGauge = 270) - } + ) + } +} + +@BasicPreview +@Composable +private fun SpeedGraphSlowPreview() { + PrezelTheme { + SpeedGraph( + userGauge = 190, + modifier = Modifier.padding(16.dp), + ) + } +} + +@BasicPreview +@Composable +private fun SpeedGraphFastPreview() { + PrezelTheme { + SpeedGraph( + userGauge = 270, + modifier = Modifier.padding(16.dp), + ) } } From 7fb665fec8ca0f325ed6e1484cb4b8ad8120d6dc Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 11 May 2026 21:17:42 +0900 Subject: [PATCH 05/19] =?UTF-8?q?refactor:=20SpeedGraph=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `SpeedGraph` 레이아웃 및 렌더링 방식 개선** * `BoxWithConstraints`를 `Box`로 대체하고, `Canvas` 직접 호출 대신 `drawWithCache`를 사용하여 그리기 관련 객체 생성 비용을 최적화했습니다. * 그래프 그리기 로직을 `SpeedGaugeArc` 컴포넌트로 분리하고, 중앙 수치 표시 로직을 `SpeedValueLabel`로 명확하게 명명했습니다. * 하단 범위 레이블(`RangeBoundLabels`)의 너비 조절 방식을 `maxWidth` 기반의 동적 패딩 계산에서 `fillMaxWidth` 비율 기반으로 변경하여 레이아웃 구조를 단순화했습니다. * **refactor: 코드 가독성 및 유틸리티 함수 개선** * `toAngle`, `sweepAngle` 등의 헬퍼 함수를 `toGraphAngle`, `graphSweepAngle` 등으로 변경하여 그래프 각도 계산임을 명확히 했습니다. * `Size.calculateGraphArcMetrics` 확장 함수를 추가하여 아크의 오프셋, 크기, 스트로크 계산 로직을 캡슐화했습니다. * 불필요한 `hasValidBaseRange` 검사 로직을 정리하고, 각도 계산 시 발생할 수 있는 예외 케이스 처리를 보완했습니다. --- .../core/ui/component/graph/SpeedGraph.kt | 161 ++++++++++-------- 1 file changed, 89 insertions(+), 72 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt index 3b937134..824d69b0 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt @@ -1,9 +1,7 @@ package com.team.prezel.core.ui.component.graph -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -16,12 +14,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme @@ -36,7 +34,7 @@ private const val DEFAULT_GOOD_UPPER_BOUND = 260 private const val FULL_CIRCLE_DEGREES = 270f private const val START_ANGLE = 135f private const val GRAPH_STROKE_RATIO = 0.12f -private const val GRAPH_LABEL_HORIZONTAL_PADDING_RATIO = 0.1875f +private const val GRAPH_LABEL_CONTENT_WIDTH_RATIO = 0.625f private val GRAPH_SIZE = 160.dp data class SpeedGraphColors( @@ -77,96 +75,108 @@ fun SpeedGraph( baseRange: IntRange = IntRange(DEFAULT_LOWER_BOUND, DEFAULT_UPPER_BOUND), colors: SpeedGraphColors = SpeedGraphColors.getDefault(), ) { - BoxWithConstraints( + Box( modifier = modifier .size(GRAPH_SIZE) .padding(horizontal = PrezelTheme.spacing.V4) .padding(top = PrezelTheme.spacing.V8), ) { - SpeedGraphChart( + SpeedGraphContent( userGauge = userGauge, goodRange = goodRange, baseRange = baseRange, colors = colors, ) - RangeBounds( + RangeBoundLabels( lowerBound = baseRange.first, upperBound = baseRange.last, modifier = Modifier.align(Alignment.BottomCenter), - horizontalPadding = maxWidth * GRAPH_LABEL_HORIZONTAL_PADDING_RATIO, ) } } @Composable -private fun SpeedGraphChart( +private fun SpeedGraphContent( userGauge: Int, goodRange: IntRange, baseRange: IntRange, colors: SpeedGraphColors, modifier: Modifier = Modifier, ) { - val hasValidBaseRange = baseRange.last > baseRange.first - val clampedGoodRange = goodRange.intersect(baseRange) - Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Canvas(modifier = Modifier.fillMaxSize()) { - val graphWidthPx = min(size.width, size.height) - val strokeWidthPx = (graphWidthPx / 2f) * GRAPH_STROKE_RATIO - val arcDiameter = graphWidthPx - strokeWidthPx - val arcTopLeft = Offset( - x = (size.width - arcDiameter) / 2f, - y = (size.height - arcDiameter) / 2f, - ) - val arcSize = Size(width = arcDiameter, height = arcDiameter) - val arcStroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round) + SpeedGaugeArc( + userGauge = userGauge, + goodRange = goodRange, + baseRange = baseRange, + colors = colors, + ) - drawArc( - color = colors.baseTrackColor, - startAngle = START_ANGLE, - sweepAngle = FULL_CIRCLE_DEGREES, - useCenter = false, - topLeft = arcTopLeft, - size = arcSize, - style = arcStroke, - ) + SpeedValueLabel(userGauge = userGauge) + } +} + +@Composable +private fun SpeedGaugeArc( + userGauge: Int, + goodRange: IntRange, + baseRange: IntRange, + colors: SpeedGraphColors, +) { + val clampedGoodRange = goodRange.intersect(baseRange) + val goodRangeStartAngle = clampedGoodRange.first.toGraphAngle(baseRange) + val goodRangeSweepAngle = clampedGoodRange.graphSweepAngle(baseRange) + val userGaugeSweepAngle = userGauge.graphSweepAngleFromStart(baseRange) + val userGaugeColor = if (userGauge in goodRange) colors.goodGaugeColor else colors.badGaugeColor - if (hasValidBaseRange && !clampedGoodRange.isEmpty()) { - drawArc( - color = colors.goodRangeColor, - startAngle = clampedGoodRange.first.toAngle(baseRange), - sweepAngle = clampedGoodRange.sweepAngle(baseRange), - useCenter = false, - topLeft = arcTopLeft, - size = arcSize, - style = arcStroke, - ) - } + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val (arcTopLeft: Offset, arcSize: Size, arcStroke: Stroke) = size.calculateGraphArcMetrics() - if (hasValidBaseRange) { - drawArc( - color = if (userGauge in goodRange) colors.goodGaugeColor else colors.badGaugeColor, - startAngle = START_ANGLE, - sweepAngle = userGauge.sweepFromStart(baseRange = baseRange), - useCenter = false, - topLeft = arcTopLeft, - size = arcSize, - style = arcStroke, - ) - } - } + onDrawBehind { + drawArc( + color = colors.baseTrackColor, + startAngle = START_ANGLE, + sweepAngle = FULL_CIRCLE_DEGREES, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = arcStroke, + ) - SpmDisplay(userGauge = userGauge) - } + if (!clampedGoodRange.isEmpty()) { + drawArc( + color = colors.goodRangeColor, + startAngle = goodRangeStartAngle, + sweepAngle = goodRangeSweepAngle, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = arcStroke, + ) + } + + drawArc( + color = userGaugeColor, + startAngle = START_ANGLE, + sweepAngle = userGaugeSweepAngle, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = arcStroke, + ) + } + }, + ) } -/** 현재 속도 값과 단위를 그래프 중앙에 표시합니다. */ @Composable -private fun SpmDisplay(userGauge: Int) { +private fun SpeedValueLabel(userGauge: Int) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = userGauge.toString(), @@ -184,18 +194,14 @@ private fun SpmDisplay(userGauge: Int) { } } -/** 기준 범위의 시작값과 끝값을 그래프 하단에 배치합니다. */ @Composable -private fun RangeBounds( +private fun RangeBoundLabels( lowerBound: Int, upperBound: Int, - horizontalPadding: Dp, modifier: Modifier = Modifier, ) { Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding), + modifier = modifier.fillMaxWidth(GRAPH_LABEL_CONTENT_WIDTH_RATIO), horizontalArrangement = Arrangement.SpaceBetween, ) { ProvideTextStyle( @@ -207,27 +213,38 @@ private fun RangeBounds( } } -/** 기준 범위 안의 값을 그래프 각도로 변환합니다. */ -private fun Int.toAngle(baseRange: IntRange): Float { +private fun Size.calculateGraphArcMetrics(): Triple { + val graphWidthPx = min(width, height) + val strokeWidthPx = (graphWidthPx / 2f) * GRAPH_STROKE_RATIO + val arcDiameter = graphWidthPx - strokeWidthPx + + return Triple( + first = Offset( + x = (width - arcDiameter) / 2f, + y = (height - arcDiameter) / 2f, + ), + second = Size(width = arcDiameter, height = arcDiameter), + third = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + ) +} + +private fun Int.toGraphAngle(baseRange: IntRange): Float { if (baseRange.last <= baseRange.first) return START_ANGLE val progress = (this - baseRange.first).toFloat() / (baseRange.last - baseRange.first).toFloat() return START_ANGLE + (FULL_CIRCLE_DEGREES * progress.coerceIn(0f, 1f)) } -/** 그래프 시작점부터 현재 값까지의 sweep 각도를 계산합니다. */ -private fun Int.sweepFromStart(baseRange: IntRange): Float = toAngle(baseRange) - START_ANGLE +private fun Int.graphSweepAngleFromStart(baseRange: IntRange): Float = toGraphAngle(baseRange) - START_ANGLE -/** 기준 범위 안에서 겹치는 구간의 sweep 각도를 계산합니다. */ -private fun IntRange.sweepAngle(baseRange: IntRange): Float { +private fun IntRange.graphSweepAngle(baseRange: IntRange): Float { if (isEmpty()) return 0f return max( - last.toAngle(baseRange) - first.toAngle(baseRange), + last.toGraphAngle(baseRange) - first.toGraphAngle(baseRange), 0f, ) } -/** 두 범위가 겹치는 구간만 남깁니다. */ private fun IntRange.intersect(other: IntRange): IntRange { val start = max(first, other.first) val endInclusive = minOf(last, other.last) From 4e0e5c7ba804d1e196df7d8621130489264c55ad Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 11 May 2026 22:29:03 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=EB=A7=89=EB=8C=80=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84(StickGraph)=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20UI=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20=EC=9A=94=EC=86=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `StickGraph` 컴포넌트 및 데이터 모델 정의** * 맞춤법(SPELLING) 및 주술호응(GRAMMAR) 통계를 시각화하는 `StickGraph` 컴포넌트를 추가했습니다. * 데이터 전달을 위한 `StickData` 클래스와 항목 구분을 위한 `StickGraphItemType` Enum을 정의했습니다. * 입력 데이터 중 최대 수치에 비례하여 막대 높이를 계산하는 `toStickHeight` 로직과 항목별 테마 색상 적용 로직을 구현했습니다. * **build: `kotlinx-collections-immutable` 의존성 추가** * Compose UI 컴포넌트의 안정성(Stability) 최적화를 위해 `ImmutableList`를 사용할 수 있도록 `core:ui` 모듈에 관련 라이브러리를 추가했습니다. * **resource: 그래프 레이블용 문자열 리소스 추가** * 그래프 하단에 표시될 '맞춤법' 및 '주술호응' 명칭에 대한 문자열 리소스를 추가했습니다. --- Prezel/core/ui/build.gradle.kts | 1 + .../core/ui/component/graph/StickGraph.kt | 156 ++++++++++++++++++ .../core/ui/src/main/res/values/strings.xml | 4 + 3 files changed, 161 insertions(+) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt diff --git a/Prezel/core/ui/build.gradle.kts b/Prezel/core/ui/build.gradle.kts index 560955bf..fbc9f29d 100644 --- a/Prezel/core/ui/build.gradle.kts +++ b/Prezel/core/ui/build.gradle.kts @@ -10,4 +10,5 @@ dependencies { implementation(projects.coreDesignsystem) implementation(projects.coreModel) implementation(libs.lottie.compose) + implementation(libs.kotlinx.collections.immutable) } diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt new file mode 100644 index 00000000..3f732e6f --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt @@ -0,0 +1,156 @@ +package com.team.prezel.core.ui.component.graph + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +private const val STICK_GRAPH_MAX_HEIGHT = 160 +private val STICK_GRAPH_ITEM_WIDTH = 52.dp + +enum class StickGraphItemType { + SPELLING, + GRAMMAR, +} + +@Immutable +data class StickData( + val count: Int, + val itemType: StickGraphItemType, +) + +@Immutable +private data class StickGraphBar( + val count: Int, + val height: Dp, + val color: Color, + val label: String, +) + +@Composable +fun StickGraph( + data: ImmutableList, + modifier: Modifier = Modifier, +) { + require(data.size == StickGraphItemType.entries.size) { + "StickGraph 데이터는 각 항목별로 1개씩, 총 ${StickGraphItemType.entries.size}개가 필요합니다." + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + verticalAlignment = Alignment.Bottom, + ) { + data.toStickGraphBars().forEach { bar -> + StickGraphItem(bar = bar) + } + } +} + +@Composable +private fun StickGraphItem( + bar: StickGraphBar, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + ProvideTextStyle(PrezelTheme.typography.body3Medium.copy(color = PrezelTheme.colors.textRegular)) { + Text(text = bar.count.toString()) + + Box( + modifier = modifier + .size( + width = STICK_GRAPH_ITEM_WIDTH, + height = bar.height, + ).clip(PrezelTheme.shapes.V4) + .background(color = bar.color), + ) + + Text(text = bar.label) + } + } +} + +@Composable +private fun ImmutableList.toStickGraphBars(): ImmutableList { + val aggregatedData = aggregateByItemType() + val maxCount = aggregatedData.maxOf(StickData::count) + + return aggregatedData + .map { data -> + StickGraphBar( + count = data.count, + height = data.count.toStickHeight(maxCount = maxCount), + color = data.itemType.itemColor(), + label = data.itemType.itemLabel(), + ) + }.toImmutableList() +} + +private fun ImmutableList.aggregateByItemType(): ImmutableList = + this + .groupBy { stickItem -> stickItem.itemType } + .map { (itemType, items) -> + StickData( + count = items.sumOf { item -> item.count }, + itemType = itemType, + ) + }.toImmutableList() + +private fun Int.toStickHeight(maxCount: Int): Dp { + val heightRatio = this.toFloat() / maxCount + return (heightRatio * STICK_GRAPH_MAX_HEIGHT).dp +} + +@Composable +private fun StickGraphItemType.itemColor(): Color = + when (this) { + StickGraphItemType.SPELLING -> PrezelTheme.colors.accentPurpleRegular + StickGraphItemType.GRAMMAR -> PrezelTheme.colors.accentTealRegular + } + +@Composable +private fun StickGraphItemType.itemLabel(): String = + when (this) { + StickGraphItemType.SPELLING -> R.string.core_ui_impl_stick_graph_spelling_label + StickGraphItemType.GRAMMAR -> R.string.core_ui_impl_stick_graph_grammar_label + }.let { resId -> stringResource(resId) } + +@Preview(showBackground = true) +@Composable +private fun StickGraphPreview() { + PrezelTheme { + Box( + modifier = Modifier.padding(12.dp), + ) { + StickGraph( + data = listOf( + StickData(count = 2, itemType = StickGraphItemType.SPELLING), + StickData(count = 1, itemType = StickGraphItemType.GRAMMAR), + ).toImmutableList(), + ) + } + } +} diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index cfeaeab7..32350f1b 100644 --- a/Prezel/core/ui/src/main/res/values/strings.xml +++ b/Prezel/core/ui/src/main/res/values/strings.xml @@ -7,4 +7,8 @@ 끝까지 해냄 컨디션 최고 감 잡았다 + + + 맞춤법 + 주술호응 From e5e0b962f9df247442a016097554538bc045ac60 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 12 May 2026 00:39:26 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=8B=9C=EA=B0=81=ED=99=94=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20`CardGraph`=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 커스텀 선형 차트 컴포넌트 `CardGraph` 추가** * 발화율(`speech`)과 대본 일치율(`scriptMatch`) 데이터를 시각화하는 커스텀 라인 차트를 구현했습니다. * `Canvas`를 사용하여 데이터 라인, 배경 그리드, 선택 마커 및 가이드 라인을 직접 드로잉했습니다. * 데이터가 일정 개수(7개)를 초과할 경우 가로 스크롤이 가능하도록 구현했으며, 탭 제스처로 특정 시점의 데이터를 선택하는 인터랙션을 추가했습니다. * **feat: 차트 상세 정보 및 범례 영역(`DetailContainer`) 구현** * 차트 하단에 선택된 시점의 수치를 퍼센트(%)로 표시하거나, 전체 기간의 증감 수치를 퍼센트 포인트(%p)로 노출하는 범례 영역을 추가했습니다. * `IntrinsicSize.Min`과 커스텀 디바이더를 활용하여 가변적인 텍스트 길이에 대응하는 레이아웃을 구성했습니다. * **style: 컴포넌트 프리뷰 및 유틸리티 로직 구성** * 좌표 기반의 가장 가까운 인덱스 탐색(`findClosestIndex`) 및 수치 포맷팅 함수를 정의했습니다. * 기본, 선택 상태, 스크롤 가능 상태 등 다양한 케이스를 확인할 수 있는 Compose Preview를 추가했습니다. --- .../core/ui/component/graph/CardGraph.kt | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt new file mode 100644 index 00000000..0dd8436d --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt @@ -0,0 +1,594 @@ +package com.team.prezel.core.ui.component.graph + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.PrezelDividerType +import com.team.prezel.core.designsystem.component.PrezelVerticalDivider +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.util.noRippleClickable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +private const val SCROLL_THRESHOLD = 7 +private val X_AXIS_LABEL_WIDTH = 28.dp +private const val CHART_DASH_STROKE_WIDTH = 1f +private const val CHART_BASELINE_STROKE_WIDTH = 2f +private val CHART_LINE_STROKE_WIDTH = 2.dp +private val CHART_SELECTED_GUIDE_STROKE_WIDTH = 1.dp +private val CHART_SELECTED_INNER_DOT_SIZE = 6.dp +private val CHART_SELECTED_OUTER_DOT_SIZE = 10.dp +private val CHART_SELECTED_TRIANGLE_WIDTH = 6.dp +private val CHART_SELECTED_TRIANGLE_HEIGHT = 6.dp +private val CARD_GRAPH_PREVIEW_WIDTH = 320.dp + +data class CardGraphItem( + val speech: Float, + val scriptMatch: Float, +) + +@Composable +fun CardGraph( + items: ImmutableList, + modifier: Modifier = Modifier, + selectedItemIndex: Int? = null, + onSelectItem: (Int) -> Unit = {}, +) { + require(items.isNotEmpty()) {} + val enableScroll = items.size > SCROLL_THRESHOLD + val xAxisCenters = remember(items.size) { + mutableStateListOf().apply { + repeat(items.size) { add(Float.NaN) } + } + } + + BoxWithConstraints { + val requireWidth = maxWidth + val contentWidth = if (enableScroll) { + X_AXIS_LABEL_WIDTH + ((requireWidth - X_AXIS_LABEL_WIDTH) / (SCROLL_THRESHOLD - 1)) * (items.size - 1) + } else { + requireWidth + } + + Column(modifier = modifier.cardGraph()) { + Column( + modifier = Modifier + .weight(1f) + .then( + if (enableScroll) Modifier.horizontalScroll(rememberScrollState()) else Modifier, + ), + ) { + LinearChart( + items = items, + xAxisCenters = xAxisCenters, + selectedItemIndex = selectedItemIndex, + onSelectItem = onSelectItem, + modifier = Modifier + .width(contentWidth) + .weight(1f), + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) + XAxisRow( + size = items.size, + xAxisCenters = xAxisCenters, + onSelectItem = onSelectItem, + modifier = Modifier.width(contentWidth), + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) + } + + DetailContainer( + items = items, + selectedItemIndex = selectedItemIndex, + ) + } + } +} + +@Composable +private fun Modifier.cardGraph(): Modifier = + this + .fillMaxWidth() + .aspectRatio(320 / 212f) + .clip(PrezelTheme.shapes.V8) + .background(color = PrezelTheme.colors.bgMedium) + .padding( + vertical = PrezelTheme.spacing.V12, + horizontal = PrezelTheme.spacing.V16, + ) + +@Composable +private fun LinearChart( + items: ImmutableList, + xAxisCenters: List, + selectedItemIndex: Int?, + onSelectItem: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val dashColor = PrezelTheme.colors.borderSmall + val underlineColor = PrezelTheme.colors.borderRegular + val speechColor = PrezelTheme.colors.feedbackGoodRegular + val scriptMatchColor = PrezelTheme.colors.feedbackWarningRegular + val selectedGuideColor = PrezelTheme.colors.borderMedium + val density = LocalDensity.current + val lineStrokeWidthPx = with(density) { CHART_LINE_STROKE_WIDTH.toPx() } + val selectedGuideStrokeWidthPx = with(density) { CHART_SELECTED_GUIDE_STROKE_WIDTH.toPx() } + val innerDotRadiusPx = with(density) { CHART_SELECTED_INNER_DOT_SIZE.toPx() / 2f } + val outerDotRadiusPx = with(density) { CHART_SELECTED_OUTER_DOT_SIZE.toPx() / 2f } + val selectedTriangleWidthPx = with(density) { CHART_SELECTED_TRIANGLE_WIDTH.toPx() } + val selectedTriangleHeightPx = with(density) { CHART_SELECTED_TRIANGLE_HEIGHT.toPx() } + + Box( + modifier = modifier.pointerInput(xAxisCenters, items.size) { + detectTapGestures { tapOffset -> + xAxisCenters.findClosestIndex(tapOffset.x)?.let(onSelectItem) + } + }, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val baselineY = size.height - (CHART_BASELINE_STROKE_WIDTH / 2f) + val chartHeight = baselineY + val speechOffsets = items.mapIndexedNotNull { index, item -> + xAxisCenters + .getOrNull(index) + ?.takeUnless(Float::isNaN) + ?.let { centerX -> + Offset( + x = centerX, + y = chartHeight - (item.speech.coerceIn(0f, 1f) * chartHeight), + ) + } + } + val scriptMatchOffsets = items.mapIndexedNotNull { index, item -> + xAxisCenters + .getOrNull(index) + ?.takeUnless(Float::isNaN) + ?.let { centerX -> + Offset( + x = centerX, + y = chartHeight - (item.scriptMatch.coerceIn(0f, 1f) * chartHeight), + ) + } + } + + repeat(items.size) { index -> + val centerX = xAxisCenters.getOrNull(index) + if (centerX != null && !centerX.isNaN()) { + drawLine( + color = dashColor, + start = Offset(x = centerX, y = 0f), + end = Offset(x = centerX, y = size.height), + strokeWidth = CHART_DASH_STROKE_WIDTH, + cap = StrokeCap.Butt, + pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(1f, 2f), + ), + ) + } + } + + drawLine( + color = underlineColor, + start = Offset(x = 0f, y = baselineY), + end = Offset(x = size.width, y = baselineY), + strokeWidth = CHART_BASELINE_STROKE_WIDTH, + ) + + if (selectedItemIndex != null) { + drawSelectedGuide( + xAxisCenters = xAxisCenters, + selectedIndex = selectedItemIndex, + color = selectedGuideColor, + baselineY = baselineY, + strokeWidth = selectedGuideStrokeWidthPx, + triangleWidth = selectedTriangleWidthPx, + triangleHeight = selectedTriangleHeightPx, + ) + } + + drawSeriesLine( + points = speechOffsets, + color = speechColor, + strokeWidth = lineStrokeWidthPx, + ) + drawSeriesLine( + points = scriptMatchOffsets, + color = scriptMatchColor, + strokeWidth = lineStrokeWidthPx, + ) + + if (selectedItemIndex != null) { + drawSelectedMarker( + points = speechOffsets, + selectedIndex = selectedItemIndex, + color = speechColor, + outerRadius = outerDotRadiusPx, + innerRadius = innerDotRadiusPx, + ) + drawSelectedMarker( + points = scriptMatchOffsets, + selectedIndex = selectedItemIndex, + color = scriptMatchColor, + outerRadius = outerDotRadiusPx, + innerRadius = innerDotRadiusPx, + ) + } + } + } +} + +@Composable +private fun XAxisRow( + size: Int, + xAxisCenters: MutableList, + onSelectItem: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + repeat(size) { index -> + Text( + text = "${index + 1}차", + style = PrezelTheme.typography.caption2Regular, + color = PrezelTheme.colors.textSmall, + modifier = Modifier + .width(X_AXIS_LABEL_WIDTH) + .noRippleClickable { onSelectItem(index) } + .onGloballyPositioned { coordinates -> + xAxisCenters[index] = coordinates.positionInParent().x + (coordinates.size.width / 2f) + }, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun DetailContainer( + items: ImmutableList, + selectedItemIndex: Int?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + LegendItem( + label = "발화", + color = PrezelTheme.colors.feedbackGoodRegular, + valueText = items.toDetailValueText( + selectedItemIndex = selectedItemIndex, + valueSelector = CardGraphItem::speech, + ), + modifier = Modifier.weight(1f), + ) + PrezelVerticalDivider( + type = PrezelDividerType.THICK, + color = PrezelTheme.colors.borderRegular, + modifier = Modifier.fillMaxHeight(), + ) + LegendItem( + label = "대본 일치율", + color = PrezelTheme.colors.feedbackWarningRegular, + valueText = items.toDetailValueText( + selectedItemIndex = selectedItemIndex, + valueSelector = CardGraphItem::scriptMatch, + ), + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun LegendItem( + label: String, + color: Color, + valueText: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding( + horizontal = PrezelTheme.spacing.V8, + vertical = PrezelTheme.spacing.V6, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(PrezelTheme.shapes.V1000) + .size(8.dp, 2.dp) + .background(color), + ) + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V4)) + Text( + text = label, + style = PrezelTheme.typography.caption2Regular, + color = color, + ) + } + + Text( + text = valueText, + style = PrezelTheme.typography.body3Bold, + color = PrezelTheme.colors.textMedium, + ) + } +} + +private fun List.toDetailValueText( + selectedItemIndex: Int?, + valueSelector: (CardGraphItem) -> Float, +): String { + val selectedItem = selectedItemIndex?.let(::getOrNull) + return if (selectedItem != null) { + selectedItem.toPercentText(valueSelector) + } else { + val pointDiff = valueSelector(last()) - valueSelector(first()) + pointDiff.toPercentPointText() + } +} + +private fun CardGraphItem.toPercentText(valueSelector: (CardGraphItem) -> Float): String = "${(valueSelector(this).coerceIn(0f, 1f) * 100).toInt()}%" + +private fun Float.toPercentPointText(): String { + val value = (this * 100).toInt() + val prefix = if (value > 0) "+" else "" + return "${prefix}$value%p" +} + +private fun DrawScope.drawSeriesLine( + points: List, + color: Color, + strokeWidth: Float, +) { + if (points.size < 2) return + + for (index in 0 until points.lastIndex) { + drawLine( + color = color, + start = points[index], + end = points[index + 1], + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } +} + +private fun DrawScope.drawSelectedMarker( + points: List, + selectedIndex: Int, + color: Color, + outerRadius: Float, + innerRadius: Float, +) { + val point = points.getOrNull(selectedIndex) ?: return + + drawCircle( + color = color.copy(alpha = 0.3f), + radius = outerRadius, + center = point, + ) + drawCircle( + color = color, + radius = innerRadius, + center = point, + ) +} + +private fun DrawScope.drawSelectedGuide( + xAxisCenters: List, + selectedIndex: Int, + color: Color, + baselineY: Float, + strokeWidth: Float, + triangleWidth: Float, + triangleHeight: Float, +) { + val centerX = xAxisCenters.getOrNull(selectedIndex)?.takeUnless(Float::isNaN) ?: return + val triangleApexY = baselineY - triangleHeight + + drawLine( + color = color, + start = Offset(x = centerX, y = 0f), + end = Offset(x = centerX, y = triangleApexY), + strokeWidth = strokeWidth, + cap = StrokeCap.Butt, + ) + + drawPath( + path = Path().apply { + moveTo(x = centerX - (triangleWidth / 2f), y = baselineY) + lineTo(x = centerX + (triangleWidth / 2f), y = baselineY) + lineTo(x = centerX, y = triangleApexY) + close() + }, + color = color, + ) +} + +private fun List.findClosestIndex(targetX: Float): Int? = + mapIndexedNotNull { index, centerX -> + centerX.takeUnless(Float::isNaN)?.let { index to kotlin.math.abs(it - targetX) } + }.minByOrNull { (_, distance) -> distance } + ?.first + +private val cardGraphPreviewItems = persistentListOf( + CardGraphItem( + speech = 0.92f, + scriptMatch = 0.88f, + ), + CardGraphItem( + speech = 0.75f, + scriptMatch = 0.81f, + ), + CardGraphItem( + speech = 0.63f, + scriptMatch = 0.58f, + ), + CardGraphItem( + speech = 0.48f, + scriptMatch = 0.71f, + ), + CardGraphItem( + speech = 0.84f, + scriptMatch = 0.79f, + ), + CardGraphItem( + speech = 0.56f, + scriptMatch = 0.64f, + ), + CardGraphItem( + speech = 0.97f, + scriptMatch = 0.91f, + ), + CardGraphItem( + speech = 0.69f, + scriptMatch = 0.73f, + ), +) + +private val cardGraphCompactPreviewItems = persistentListOf( + CardGraphItem( + speech = 0.92f, + scriptMatch = 0.88f, + ), + CardGraphItem( + speech = 0.75f, + scriptMatch = 0.81f, + ), + CardGraphItem( + speech = 0.63f, + scriptMatch = 0.58f, + ), + CardGraphItem( + speech = 0.48f, + scriptMatch = 0.71f, + ), + CardGraphItem( + speech = 0.84f, + scriptMatch = 0.79f, + ), +) + +@Composable +private fun CardGraphPreviewContainer( + items: ImmutableList, + selectedItemIndex: Int? = null, +) { + Box( + modifier = Modifier + .width(CARD_GRAPH_PREVIEW_WIDTH) + .padding(12.dp), + ) { + CardGraph( + items = items, + selectedItemIndex = selectedItemIndex, + ) + } +} + +@BasicPreview +@Composable +private fun CardGraphDefaultPreview() { + PrezelTheme { + CardGraphPreviewContainer(items = cardGraphCompactPreviewItems) + } +} + +@BasicPreview +@Composable +private fun CardGraphSelectedPointPreview() { + PrezelTheme { + CardGraphPreviewContainer( + items = cardGraphCompactPreviewItems, + selectedItemIndex = 2, + ) + } +} + +@BasicPreview +@Composable +private fun CardGraphScrollablePreview() { + PrezelTheme { + CardGraphPreviewContainer(items = cardGraphPreviewItems) + } +} + +@BasicPreview +@Composable +private fun CardGraphScrollableSelectedPointPreview() { + PrezelTheme { + CardGraphPreviewContainer( + items = cardGraphPreviewItems, + selectedItemIndex = 6, + ) + } +} + +@BasicPreview +@Composable +private fun CardGraphInteractivePreview() { + PrezelTheme { + var selectedItemIndex by remember { mutableStateOf(2) } + + Box( + modifier = Modifier + .width(CARD_GRAPH_PREVIEW_WIDTH) + .padding(12.dp), + ) { + CardGraph( + items = cardGraphPreviewItems, + selectedItemIndex = selectedItemIndex, + onSelectItem = { index -> + selectedItemIndex = if (selectedItemIndex == index) null else index + }, + ) + } + } +} From 1f0c69239bc75b309dcadd58651727bb3f4e68bc Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 12 May 2026 01:05:31 +0900 Subject: [PATCH 08/19] =?UTF-8?q?refactor:=20`CardGraph`=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=BB=A4=EC=8A=A4=ED=84=B0=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `CardGraph` UI 로직 분리 및 모듈화** * 전체 구조를 배경 및 컨테이너를 담당하는 `CardGraphContainer`와 실제 차트 내용을 담는 `CardGraphContent`로 분리했습니다. * 가로 세로 비율을 상수로 관리하도록 `CARD_GRAPH_ASPECT_RATIO`를 추가했습니다. * `items`가 비어있을 경우에 대한 에러 메시지를 구체화했습니다. * **feat: 차트 표시 옵션 파라미터 추가** * 하단 상세 정보를 제어하는 `showDetail` 파라미터를 추가했습니다. * 컨테이너 스타일(배경색, 패딩 등) 적용 여부를 선택할 수 있는 `useContainerStyle` 파라미터를 추가했습니다. * **refactor: 차트 데이터 계산 및 드로잉 로직 최적화** * `Canvas` 내부에 복잡하게 구현되어 있던 좌표 계산 로직을 `toSeriesPoints`, `mapSeriesPoints` 등 별도의 유틸리티 함수로 추출했습니다. * `toChartContentWidth`, `forEachValidCenter` 등의 확장 함수를 도입하여 코드 가독성을 높이고 중복 로직을 제거했습니다. * 차트 시리즈 포인트를 관리하는 내부 데이터 클래스 `CardGraphSeriesPoints`를 추가했습니다. * **test: 다양한 UI 케이스 확인을 위한 Preview 추가** * 상세 정보가 없는 케이스와 컨테이너 스타일이 적용되지 않은 케이스에 대한 Preview를 추가했습니다. * 인터랙티브 Preview에서 `PrezelChip`을 통해 실시간으로 옵션을 변경하며 테스트할 수 있도록 개선했습니다. --- .../core/ui/component/graph/CardGraph.kt | 297 ++++++++++++------ 1 file changed, 209 insertions(+), 88 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt index 0dd8436d..0b536014 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.PrezelDividerType import com.team.prezel.core.designsystem.component.PrezelVerticalDivider +import com.team.prezel.core.designsystem.component.chip.chip.ChipState +import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.util.noRippleClickable @@ -61,62 +63,125 @@ private val CHART_SELECTED_OUTER_DOT_SIZE = 10.dp private val CHART_SELECTED_TRIANGLE_WIDTH = 6.dp private val CHART_SELECTED_TRIANGLE_HEIGHT = 6.dp private val CARD_GRAPH_PREVIEW_WIDTH = 320.dp +private const val CARD_GRAPH_ASPECT_RATIO = 320 / 212f data class CardGraphItem( val speech: Float, val scriptMatch: Float, ) +private data class CardGraphSeriesPoints( + val speech: List, + val scriptMatch: List, +) + @Composable fun CardGraph( items: ImmutableList, modifier: Modifier = Modifier, selectedItemIndex: Int? = null, + showDetail: Boolean = true, + useContainerStyle: Boolean = true, onSelectItem: (Int) -> Unit = {}, ) { - require(items.isNotEmpty()) {} + require(items.isNotEmpty()) { "CardGraph items must not be empty." } + val enableScroll = items.size > SCROLL_THRESHOLD val xAxisCenters = remember(items.size) { mutableStateListOf().apply { repeat(items.size) { add(Float.NaN) } } } + val resolvedSelectedItemIndex = selectedItemIndex.takeIf { index -> index != null && index in items.indices } BoxWithConstraints { val requireWidth = maxWidth - val contentWidth = if (enableScroll) { - X_AXIS_LABEL_WIDTH + ((requireWidth - X_AXIS_LABEL_WIDTH) / (SCROLL_THRESHOLD - 1)) * (items.size - 1) - } else { - requireWidth + val contentWidth = requireWidth.toChartContentWidth( + itemCount = items.size, + enableScroll = enableScroll, + ) + + CardGraphContainer( + modifier = modifier, + useContainerStyle = useContainerStyle, + ) { + CardGraphContent( + items = items, + xAxisCenters = xAxisCenters, + contentWidth = contentWidth, + enableScroll = enableScroll, + selectedItemIndex = resolvedSelectedItemIndex, + onSelectItem = onSelectItem, + showDetail = showDetail, + ) } + } +} + +@Composable +private fun CardGraphContainer( + useContainerStyle: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val baseModifier = modifier + .fillMaxWidth() + .aspectRatio(CARD_GRAPH_ASPECT_RATIO) + val containerModifier = if (useContainerStyle) { + baseModifier + .clip(PrezelTheme.shapes.V8) + .background(color = PrezelTheme.colors.bgMedium) + .padding( + vertical = PrezelTheme.spacing.V12, + horizontal = PrezelTheme.spacing.V16, + ) + } else { + baseModifier + } + + Column(modifier = containerModifier) { + content() + } +} - Column(modifier = modifier.cardGraph()) { - Column( +@Composable +private fun CardGraphContent( + items: ImmutableList, + xAxisCenters: MutableList, + contentWidth: androidx.compose.ui.unit.Dp, + enableScroll: Boolean, + selectedItemIndex: Int?, + onSelectItem: (Int) -> Unit, + showDetail: Boolean, +) { + Column { + Column( + modifier = Modifier + .weight(1f, fill = false) + .then( + if (enableScroll) Modifier.horizontalScroll(rememberScrollState()) else Modifier, + ), + ) { + LinearChart( + items = items, + xAxisCenters = xAxisCenters, + selectedItemIndex = selectedItemIndex, + onSelectItem = onSelectItem, modifier = Modifier - .weight(1f) - .then( - if (enableScroll) Modifier.horizontalScroll(rememberScrollState()) else Modifier, - ), - ) { - LinearChart( - items = items, - xAxisCenters = xAxisCenters, - selectedItemIndex = selectedItemIndex, - onSelectItem = onSelectItem, - modifier = Modifier - .width(contentWidth) - .weight(1f), - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) - XAxisRow( - size = items.size, - xAxisCenters = xAxisCenters, - onSelectItem = onSelectItem, - modifier = Modifier.width(contentWidth), - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) - } + .width(contentWidth) + .weight(1f, fill = false), + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) + XAxisRow( + size = items.size, + xAxisCenters = xAxisCenters, + onSelectItem = onSelectItem, + modifier = Modifier.width(contentWidth), + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) + } + if (showDetail) { DetailContainer( items = items, selectedItemIndex = selectedItemIndex, @@ -125,18 +190,6 @@ fun CardGraph( } } -@Composable -private fun Modifier.cardGraph(): Modifier = - this - .fillMaxWidth() - .aspectRatio(320 / 212f) - .clip(PrezelTheme.shapes.V8) - .background(color = PrezelTheme.colors.bgMedium) - .padding( - vertical = PrezelTheme.spacing.V12, - horizontal = PrezelTheme.spacing.V16, - ) - @Composable private fun LinearChart( items: ImmutableList, @@ -167,44 +220,22 @@ private fun LinearChart( ) { Canvas(modifier = Modifier.fillMaxSize()) { val baselineY = size.height - (CHART_BASELINE_STROKE_WIDTH / 2f) - val chartHeight = baselineY - val speechOffsets = items.mapIndexedNotNull { index, item -> - xAxisCenters - .getOrNull(index) - ?.takeUnless(Float::isNaN) - ?.let { centerX -> - Offset( - x = centerX, - y = chartHeight - (item.speech.coerceIn(0f, 1f) * chartHeight), - ) - } - } - val scriptMatchOffsets = items.mapIndexedNotNull { index, item -> - xAxisCenters - .getOrNull(index) - ?.takeUnless(Float::isNaN) - ?.let { centerX -> - Offset( - x = centerX, - y = chartHeight - (item.scriptMatch.coerceIn(0f, 1f) * chartHeight), - ) - } - } + val points = items.toSeriesPoints( + xAxisCenters = xAxisCenters, + chartHeight = baselineY, + ) - repeat(items.size) { index -> - val centerX = xAxisCenters.getOrNull(index) - if (centerX != null && !centerX.isNaN()) { - drawLine( - color = dashColor, - start = Offset(x = centerX, y = 0f), - end = Offset(x = centerX, y = size.height), - strokeWidth = CHART_DASH_STROKE_WIDTH, - cap = StrokeCap.Butt, - pathEffect = PathEffect.dashPathEffect( - intervals = floatArrayOf(1f, 2f), - ), - ) - } + xAxisCenters.forEachValidCenter { centerX -> + drawLine( + color = dashColor, + start = Offset(x = centerX, y = 0f), + end = Offset(x = centerX, y = size.height), + strokeWidth = CHART_DASH_STROKE_WIDTH, + cap = StrokeCap.Butt, + pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(1f, 2f), + ), + ) } drawLine( @@ -227,26 +258,26 @@ private fun LinearChart( } drawSeriesLine( - points = speechOffsets, + points = points.speech, color = speechColor, strokeWidth = lineStrokeWidthPx, ) drawSeriesLine( - points = scriptMatchOffsets, + points = points.scriptMatch, color = scriptMatchColor, strokeWidth = lineStrokeWidthPx, ) if (selectedItemIndex != null) { drawSelectedMarker( - points = speechOffsets, + points = points.speech, selectedIndex = selectedItemIndex, color = speechColor, outerRadius = outerDotRadiusPx, innerRadius = innerDotRadiusPx, ) drawSelectedMarker( - points = scriptMatchOffsets, + points = points.scriptMatch, selectedIndex = selectedItemIndex, color = scriptMatchColor, outerRadius = outerDotRadiusPx, @@ -382,6 +413,56 @@ private fun Float.toPercentPointText(): String { return "${prefix}$value%p" } +private fun androidx.compose.ui.unit.Dp.toChartContentWidth( + itemCount: Int, + enableScroll: Boolean, +): androidx.compose.ui.unit.Dp = + if (enableScroll) { + X_AXIS_LABEL_WIDTH + ((this - X_AXIS_LABEL_WIDTH) / (SCROLL_THRESHOLD - 1)) * (itemCount - 1) + } else { + this + } + +private fun List.forEachValidCenter(action: (Float) -> Unit) { + forEach { centerX -> + centerX.takeUnless(Float::isNaN)?.let(action) + } +} + +private fun List.toSeriesPoints( + xAxisCenters: List, + chartHeight: Float, +): CardGraphSeriesPoints = + CardGraphSeriesPoints( + speech = mapSeriesPoints( + xAxisCenters = xAxisCenters, + chartHeight = chartHeight, + valueSelector = CardGraphItem::speech, + ), + scriptMatch = mapSeriesPoints( + xAxisCenters = xAxisCenters, + chartHeight = chartHeight, + valueSelector = CardGraphItem::scriptMatch, + ), + ) + +private fun List.mapSeriesPoints( + xAxisCenters: List, + chartHeight: Float, + valueSelector: (CardGraphItem) -> Float, +): List = + mapIndexedNotNull { index, item -> + xAxisCenters + .getOrNull(index) + ?.takeUnless(Float::isNaN) + ?.let { centerX -> + Offset( + x = centerX, + y = chartHeight - (valueSelector(item).coerceIn(0f, 1f) * chartHeight), + ) + } + } + private fun DrawScope.drawSeriesLine( points: List, color: Color, @@ -520,6 +601,8 @@ private val cardGraphCompactPreviewItems = persistentListOf( private fun CardGraphPreviewContainer( items: ImmutableList, selectedItemIndex: Int? = null, + showDetail: Boolean = true, + useContainerStyle: Boolean = true, ) { Box( modifier = Modifier @@ -529,6 +612,8 @@ private fun CardGraphPreviewContainer( CardGraph( items = items, selectedItemIndex = selectedItemIndex, + showDetail = showDetail, + useContainerStyle = useContainerStyle, ) } } @@ -571,20 +656,56 @@ private fun CardGraphScrollableSelectedPointPreview() { } } +@BasicPreview +@Composable +private fun CardGraphWithoutDetailPreview() { + PrezelTheme { + CardGraphPreviewContainer( + items = cardGraphCompactPreviewItems, + showDetail = false, + ) + } +} + +@BasicPreview +@Composable +private fun CardGraphWithoutContainerStylePreview() { + PrezelTheme { + CardGraphPreviewContainer( + items = cardGraphCompactPreviewItems, + useContainerStyle = false, + ) + } +} + @BasicPreview @Composable private fun CardGraphInteractivePreview() { PrezelTheme { var selectedItemIndex by remember { mutableStateOf(2) } + var showDetail by remember { mutableStateOf(true) } + var useContainerStyle by remember { mutableStateOf(true) } + + Column(modifier = Modifier.fillMaxWidth().padding(4.dp)) { + Row(modifier = Modifier.padding(4.dp)) { + PrezelChip( + text = "Detail", + state = if (showDetail) ChipState.ACTIVE else ChipState.DEFAULT, + modifier = Modifier.noRippleClickable { showDetail = !showDetail }, + ) + Spacer(modifier = Modifier.width(4.dp)) + PrezelChip( + text = "Container", + state = if (useContainerStyle) ChipState.ACTIVE else ChipState.DEFAULT, + modifier = Modifier.noRippleClickable { useContainerStyle = !useContainerStyle }, + ) + } - Box( - modifier = Modifier - .width(CARD_GRAPH_PREVIEW_WIDTH) - .padding(12.dp), - ) { CardGraph( items = cardGraphPreviewItems, selectedItemIndex = selectedItemIndex, + showDetail = showDetail, + useContainerStyle = useContainerStyle, onSelectItem = { index -> selectedItemIndex = if (selectedItemIndex == index) null else index }, From 1b0cc284783a5fce93cb4f8180fbe66450487c78 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 12 May 2026 01:54:38 +0900 Subject: [PATCH 09/19] =?UTF-8?q?refactor:=20CardGraph=20UI=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EB=A6=AC=EC=86=8C=EC=8A=A4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: CardGraph 내부 상태 관리 및 드로잉 로직 개선** * `CardGraphUiState`, `CardGraphColors`, `CardGraphDimensions` 등 내부 데이터 클래스를 정의하여 UI 상태와 드로잉 관련 설정을 체계화했습니다. * 차트의 포인트 및 베이스라인 계산 로직을 `toChartState` 확장 함수로 분리하여 `LinearChart`의 가독성을 높였습니다. * `XAxisRow`에서 직접 `MutableList`를 수정하던 방식을 `onChangePosition` 콜백을 통한 업데이트 방식으로 리팩터링했습니다. * 가로 스크롤 시 `overscrollEffect = null`을 적용하여 시각적 일관성을 확보했습니다. * **feat: UI 문자열 리소스화 및 다국어 대응 준비** * 차트 X축 라벨("%1$d차"), 범례("발화", "대본 일치율"), 프리뷰용 칩 텍스트 등을 `strings.xml`로 추출했습니다. * 컴포넌트 내 하드코딩된 문자열을 `stringResource` 사용으로 대체했습니다. * **style: 코드 정리 및 프리뷰 최적화** * 불필요한 상수(`CARD_GRAPH_PREVIEW_WIDTH`)를 제거하고 프리뷰 레이아웃에 `fillMaxWidth()`를 적용하여 반응형 구조로 변경했습니다. * `ImmutableList` 활용을 강화하고 NaN 좌표 처리를 위한 `validCenterOrNull` 등 헬퍼 함수를 추가했습니다. * 고정된 가로세로 비율(`CARD_GRAPH_ASPECT_RATIO`)을 `1.5f`로 단순화했습니다. --- .../core/ui/component/graph/CardGraph.kt | 306 +++++++++++------- .../core/ui/src/main/res/values/strings.xml | 5 + 2 files changed, 187 insertions(+), 124 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt index 0b536014..d70ae0da 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt @@ -40,7 +40,9 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.component.PrezelDividerType import com.team.prezel.core.designsystem.component.PrezelVerticalDivider @@ -48,9 +50,11 @@ import com.team.prezel.core.designsystem.component.chip.chip.ChipState import com.team.prezel.core.designsystem.component.chip.chip.PrezelChip import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.R import com.team.prezel.core.ui.util.noRippleClickable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList private const val SCROLL_THRESHOLD = 7 private val X_AXIS_LABEL_WIDTH = 28.dp @@ -62,12 +66,30 @@ private val CHART_SELECTED_INNER_DOT_SIZE = 6.dp private val CHART_SELECTED_OUTER_DOT_SIZE = 10.dp private val CHART_SELECTED_TRIANGLE_WIDTH = 6.dp private val CHART_SELECTED_TRIANGLE_HEIGHT = 6.dp -private val CARD_GRAPH_PREVIEW_WIDTH = 320.dp -private const val CARD_GRAPH_ASPECT_RATIO = 320 / 212f +private const val CARD_GRAPH_ASPECT_RATIO = 1.5f -data class CardGraphItem( - val speech: Float, - val scriptMatch: Float, +private data class CardGraphUiState( + val enableScroll: Boolean, + val contentWidth: Dp, + val xAxisCenters: ImmutableList, + val selectedItemIndex: Int?, +) + +private data class CardGraphColors( + val dash: Color, + val baseline: Color, + val speech: Color, + val scriptMatch: Color, + val selectedGuide: Color, +) + +private data class CardGraphDimensions( + val lineStrokeWidthPx: Float, + val selectedGuideStrokeWidthPx: Float, + val innerDotRadiusPx: Float, + val outerDotRadiusPx: Float, + val selectedTriangleWidthPx: Float, + val selectedTriangleHeightPx: Float, ) private data class CardGraphSeriesPoints( @@ -75,6 +97,18 @@ private data class CardGraphSeriesPoints( val scriptMatch: List, ) +private data class CardGraphChartState( + val baselineY: Float, + val seriesPoints: CardGraphSeriesPoints, + val validXAxisCenters: List, + val selectedItemIndex: Int?, +) + +data class CardGraphItem( + val speech: Float, + val scriptMatch: Float, +) + @Composable fun CardGraph( items: ImmutableList, @@ -86,19 +120,14 @@ fun CardGraph( ) { require(items.isNotEmpty()) { "CardGraph items must not be empty." } - val enableScroll = items.size > SCROLL_THRESHOLD - val xAxisCenters = remember(items.size) { - mutableStateListOf().apply { - repeat(items.size) { add(Float.NaN) } - } - } - val resolvedSelectedItemIndex = selectedItemIndex.takeIf { index -> index != null && index in items.indices } + val xAxisCenters = rememberCardGraphXAxisCenters(itemCount = items.size) BoxWithConstraints { - val requireWidth = maxWidth - val contentWidth = requireWidth.toChartContentWidth( - itemCount = items.size, - enableScroll = enableScroll, + val uiState = CardGraphUiState( + enableScroll = items.shouldEnableHorizontalScroll(), + contentWidth = maxWidth.toChartContentWidth(itemCount = items.size), + xAxisCenters = xAxisCenters.toImmutableList(), + selectedItemIndex = items.resolveSelectedItemIndex(selectedItemIndex), ) CardGraphContainer( @@ -107,10 +136,10 @@ fun CardGraph( ) { CardGraphContent( items = items, - xAxisCenters = xAxisCenters, - contentWidth = contentWidth, - enableScroll = enableScroll, - selectedItemIndex = resolvedSelectedItemIndex, + uiState = uiState, + onChangePosition = { index, center -> + xAxisCenters[index] = center + }, onSelectItem = onSelectItem, showDetail = showDetail, ) @@ -147,10 +176,8 @@ private fun CardGraphContainer( @Composable private fun CardGraphContent( items: ImmutableList, - xAxisCenters: MutableList, - contentWidth: androidx.compose.ui.unit.Dp, - enableScroll: Boolean, - selectedItemIndex: Int?, + uiState: CardGraphUiState, + onChangePosition: (index: Int, center: Float) -> Unit, onSelectItem: (Int) -> Unit, showDetail: Boolean, ) { @@ -159,24 +186,30 @@ private fun CardGraphContent( modifier = Modifier .weight(1f, fill = false) .then( - if (enableScroll) Modifier.horizontalScroll(rememberScrollState()) else Modifier, + if (uiState.enableScroll) { + Modifier.horizontalScroll( + state = rememberScrollState(), + overscrollEffect = null, + ) + } else { + Modifier + }, ), ) { LinearChart( items = items, - xAxisCenters = xAxisCenters, - selectedItemIndex = selectedItemIndex, + uiState = uiState, onSelectItem = onSelectItem, modifier = Modifier - .width(contentWidth) + .width(uiState.contentWidth) .weight(1f, fill = false), ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) XAxisRow( size = items.size, - xAxisCenters = xAxisCenters, onSelectItem = onSelectItem, - modifier = Modifier.width(contentWidth), + modifier = Modifier.width(uiState.contentWidth), + onChangePosition = onChangePosition, ) Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) } @@ -184,7 +217,7 @@ private fun CardGraphContent( if (showDetail) { DetailContainer( items = items, - selectedItemIndex = selectedItemIndex, + selectedItemIndex = uiState.selectedItemIndex, ) } } @@ -193,41 +226,30 @@ private fun CardGraphContent( @Composable private fun LinearChart( items: ImmutableList, - xAxisCenters: List, - selectedItemIndex: Int?, + uiState: CardGraphUiState, onSelectItem: (Int) -> Unit, modifier: Modifier = Modifier, ) { - val dashColor = PrezelTheme.colors.borderSmall - val underlineColor = PrezelTheme.colors.borderRegular - val speechColor = PrezelTheme.colors.feedbackGoodRegular - val scriptMatchColor = PrezelTheme.colors.feedbackWarningRegular - val selectedGuideColor = PrezelTheme.colors.borderMedium - val density = LocalDensity.current - val lineStrokeWidthPx = with(density) { CHART_LINE_STROKE_WIDTH.toPx() } - val selectedGuideStrokeWidthPx = with(density) { CHART_SELECTED_GUIDE_STROKE_WIDTH.toPx() } - val innerDotRadiusPx = with(density) { CHART_SELECTED_INNER_DOT_SIZE.toPx() / 2f } - val outerDotRadiusPx = with(density) { CHART_SELECTED_OUTER_DOT_SIZE.toPx() / 2f } - val selectedTriangleWidthPx = with(density) { CHART_SELECTED_TRIANGLE_WIDTH.toPx() } - val selectedTriangleHeightPx = with(density) { CHART_SELECTED_TRIANGLE_HEIGHT.toPx() } + val colors = cardGraphColors() + val dimensions = rememberCardGraphDimensions() Box( - modifier = modifier.pointerInput(xAxisCenters, items.size) { + modifier = modifier.pointerInput(uiState.xAxisCenters, items.size) { detectTapGestures { tapOffset -> - xAxisCenters.findClosestIndex(tapOffset.x)?.let(onSelectItem) + uiState.xAxisCenters.findClosestIndex(tapOffset.x)?.let(onSelectItem) } }, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val baselineY = size.height - (CHART_BASELINE_STROKE_WIDTH / 2f) - val points = items.toSeriesPoints( - xAxisCenters = xAxisCenters, - chartHeight = baselineY, + val chartState = items.toChartState( + xAxisCenters = uiState.xAxisCenters, + chartHeight = size.height, + selectedItemIndex = uiState.selectedItemIndex, ) - xAxisCenters.forEachValidCenter { centerX -> + chartState.validXAxisCenters.forEach { centerX -> drawLine( - color = dashColor, + color = colors.dash, start = Offset(x = centerX, y = 0f), end = Offset(x = centerX, y = size.height), strokeWidth = CHART_DASH_STROKE_WIDTH, @@ -239,49 +261,49 @@ private fun LinearChart( } drawLine( - color = underlineColor, - start = Offset(x = 0f, y = baselineY), - end = Offset(x = size.width, y = baselineY), + color = colors.baseline, + start = Offset(x = 0f, y = chartState.baselineY), + end = Offset(x = size.width, y = chartState.baselineY), strokeWidth = CHART_BASELINE_STROKE_WIDTH, ) - if (selectedItemIndex != null) { + if (chartState.selectedItemIndex != null) { drawSelectedGuide( - xAxisCenters = xAxisCenters, - selectedIndex = selectedItemIndex, - color = selectedGuideColor, - baselineY = baselineY, - strokeWidth = selectedGuideStrokeWidthPx, - triangleWidth = selectedTriangleWidthPx, - triangleHeight = selectedTriangleHeightPx, + xAxisCenters = uiState.xAxisCenters, + selectedIndex = chartState.selectedItemIndex, + color = colors.selectedGuide, + baselineY = chartState.baselineY, + strokeWidth = dimensions.selectedGuideStrokeWidthPx, + triangleWidth = dimensions.selectedTriangleWidthPx, + triangleHeight = dimensions.selectedTriangleHeightPx, ) } drawSeriesLine( - points = points.speech, - color = speechColor, - strokeWidth = lineStrokeWidthPx, + points = chartState.seriesPoints.speech, + color = colors.speech, + strokeWidth = dimensions.lineStrokeWidthPx, ) drawSeriesLine( - points = points.scriptMatch, - color = scriptMatchColor, - strokeWidth = lineStrokeWidthPx, + points = chartState.seriesPoints.scriptMatch, + color = colors.scriptMatch, + strokeWidth = dimensions.lineStrokeWidthPx, ) - if (selectedItemIndex != null) { + if (chartState.selectedItemIndex != null) { drawSelectedMarker( - points = points.speech, - selectedIndex = selectedItemIndex, - color = speechColor, - outerRadius = outerDotRadiusPx, - innerRadius = innerDotRadiusPx, + points = chartState.seriesPoints.speech, + selectedIndex = chartState.selectedItemIndex, + color = colors.speech, + outerRadius = dimensions.outerDotRadiusPx, + innerRadius = dimensions.innerDotRadiusPx, ) drawSelectedMarker( - points = points.scriptMatch, - selectedIndex = selectedItemIndex, - color = scriptMatchColor, - outerRadius = outerDotRadiusPx, - innerRadius = innerDotRadiusPx, + points = chartState.seriesPoints.scriptMatch, + selectedIndex = chartState.selectedItemIndex, + color = colors.scriptMatch, + outerRadius = dimensions.outerDotRadiusPx, + innerRadius = dimensions.innerDotRadiusPx, ) } } @@ -291,9 +313,9 @@ private fun LinearChart( @Composable private fun XAxisRow( size: Int, - xAxisCenters: MutableList, onSelectItem: (Int) -> Unit, modifier: Modifier = Modifier, + onChangePosition: (index: Int, center: Float) -> Unit, ) { Row( modifier = modifier.fillMaxWidth(), @@ -301,14 +323,18 @@ private fun XAxisRow( ) { repeat(size) { index -> Text( - text = "${index + 1}차", + text = stringResource( + id = R.string.core_ui_impl_card_graph_x_axis_label, + index + 1, + ), style = PrezelTheme.typography.caption2Regular, color = PrezelTheme.colors.textSmall, modifier = Modifier .width(X_AXIS_LABEL_WIDTH) .noRippleClickable { onSelectItem(index) } .onGloballyPositioned { coordinates -> - xAxisCenters[index] = coordinates.positionInParent().x + (coordinates.size.width / 2f) + val center = coordinates.positionInParent().x + (coordinates.size.width / 2f) + onChangePosition(index, center) }, textAlign = TextAlign.Center, ) @@ -328,7 +354,7 @@ private fun DetailContainer( .height(IntrinsicSize.Min), ) { LegendItem( - label = "발화", + label = stringResource(R.string.core_ui_impl_card_graph_speech_label), color = PrezelTheme.colors.feedbackGoodRegular, valueText = items.toDetailValueText( selectedItemIndex = selectedItemIndex, @@ -342,7 +368,7 @@ private fun DetailContainer( modifier = Modifier.fillMaxHeight(), ) LegendItem( - label = "대본 일치율", + label = stringResource(R.string.core_ui_impl_card_graph_script_match_label), color = PrezelTheme.colors.feedbackWarningRegular, valueText = items.toDetailValueText( selectedItemIndex = selectedItemIndex, @@ -400,8 +426,7 @@ private fun List.toDetailValueText( return if (selectedItem != null) { selectedItem.toPercentText(valueSelector) } else { - val pointDiff = valueSelector(last()) - valueSelector(first()) - pointDiff.toPercentPointText() + valueSelector(last()).minus(valueSelector(first())).toPercentPointText() } } @@ -413,20 +438,70 @@ private fun Float.toPercentPointText(): String { return "${prefix}$value%p" } -private fun androidx.compose.ui.unit.Dp.toChartContentWidth( - itemCount: Int, - enableScroll: Boolean, -): androidx.compose.ui.unit.Dp = - if (enableScroll) { +@Composable +private fun cardGraphColors(): CardGraphColors = + CardGraphColors( + dash = PrezelTheme.colors.borderSmall, + baseline = PrezelTheme.colors.borderRegular, + speech = PrezelTheme.colors.feedbackGoodRegular, + scriptMatch = PrezelTheme.colors.feedbackWarningRegular, + selectedGuide = PrezelTheme.colors.borderMedium, + ) + +@Composable +private fun rememberCardGraphDimensions(): CardGraphDimensions { + val density = LocalDensity.current + return with(density) { + CardGraphDimensions( + lineStrokeWidthPx = CHART_LINE_STROKE_WIDTH.toPx(), + selectedGuideStrokeWidthPx = CHART_SELECTED_GUIDE_STROKE_WIDTH.toPx(), + innerDotRadiusPx = CHART_SELECTED_INNER_DOT_SIZE.toPx() / 2f, + outerDotRadiusPx = CHART_SELECTED_OUTER_DOT_SIZE.toPx() / 2f, + selectedTriangleWidthPx = CHART_SELECTED_TRIANGLE_WIDTH.toPx(), + selectedTriangleHeightPx = CHART_SELECTED_TRIANGLE_HEIGHT.toPx(), + ) + } +} + +@Composable +private fun rememberCardGraphXAxisCenters(itemCount: Int) = + remember(itemCount) { + mutableStateListOf().apply { + repeat(itemCount) { add(Float.NaN) } + } + } + +private fun ImmutableList.shouldEnableHorizontalScroll(): Boolean = size > SCROLL_THRESHOLD + +private fun List.resolveSelectedItemIndex(selectedItemIndex: Int?): Int? = + selectedItemIndex.takeIf { index -> index != null && index in indices } + +private fun Dp.toChartContentWidth(itemCount: Int): Dp = + if (itemCount > SCROLL_THRESHOLD) { X_AXIS_LABEL_WIDTH + ((this - X_AXIS_LABEL_WIDTH) / (SCROLL_THRESHOLD - 1)) * (itemCount - 1) } else { this } -private fun List.forEachValidCenter(action: (Float) -> Unit) { - forEach { centerX -> - centerX.takeUnless(Float::isNaN)?.let(action) - } +private fun List.validCenters(): List = mapNotNull(Float::validCenterOrNull) + +private fun Float.validCenterOrNull(): Float? = takeUnless(Float::isNaN) + +private fun List.toChartState( + xAxisCenters: List, + chartHeight: Float, + selectedItemIndex: Int?, +): CardGraphChartState { + val baselineY = chartHeight - (CHART_BASELINE_STROKE_WIDTH / 2f) + return CardGraphChartState( + baselineY = baselineY, + seriesPoints = toSeriesPoints( + xAxisCenters = xAxisCenters, + chartHeight = baselineY, + ), + validXAxisCenters = xAxisCenters.validCenters(), + selectedItemIndex = selectedItemIndex, + ) } private fun List.toSeriesPoints( @@ -454,7 +529,7 @@ private fun List.mapSeriesPoints( mapIndexedNotNull { index, item -> xAxisCenters .getOrNull(index) - ?.takeUnless(Float::isNaN) + ?.validCenterOrNull() ?.let { centerX -> Offset( x = centerX, @@ -535,7 +610,7 @@ private fun DrawScope.drawSelectedGuide( private fun List.findClosestIndex(targetX: Float): Int? = mapIndexedNotNull { index, centerX -> - centerX.takeUnless(Float::isNaN)?.let { index to kotlin.math.abs(it - targetX) } + centerX.validCenterOrNull()?.let { index to kotlin.math.abs(it - targetX) } }.minByOrNull { (_, distance) -> distance } ?.first @@ -574,28 +649,7 @@ private val cardGraphPreviewItems = persistentListOf( ), ) -private val cardGraphCompactPreviewItems = persistentListOf( - CardGraphItem( - speech = 0.92f, - scriptMatch = 0.88f, - ), - CardGraphItem( - speech = 0.75f, - scriptMatch = 0.81f, - ), - CardGraphItem( - speech = 0.63f, - scriptMatch = 0.58f, - ), - CardGraphItem( - speech = 0.48f, - scriptMatch = 0.71f, - ), - CardGraphItem( - speech = 0.84f, - scriptMatch = 0.79f, - ), -) +private val cardGraphCompactPreviewItems = cardGraphPreviewItems.take(5).toImmutableList() @Composable private fun CardGraphPreviewContainer( @@ -606,7 +660,7 @@ private fun CardGraphPreviewContainer( ) { Box( modifier = Modifier - .width(CARD_GRAPH_PREVIEW_WIDTH) + .fillMaxWidth() .padding(12.dp), ) { CardGraph( @@ -686,7 +740,11 @@ private fun CardGraphInteractivePreview() { var showDetail by remember { mutableStateOf(true) } var useContainerStyle by remember { mutableStateOf(true) } - Column(modifier = Modifier.fillMaxWidth().padding(4.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + ) { Row(modifier = Modifier.padding(4.dp)) { PrezelChip( text = "Detail", @@ -695,7 +753,7 @@ private fun CardGraphInteractivePreview() { ) Spacer(modifier = Modifier.width(4.dp)) PrezelChip( - text = "Container", + text = "Background", state = if (useContainerStyle) ChipState.ACTIVE else ChipState.DEFAULT, modifier = Modifier.noRippleClickable { useContainerStyle = !useContainerStyle }, ) diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index 32350f1b..2ab7d6a4 100644 --- a/Prezel/core/ui/src/main/res/values/strings.xml +++ b/Prezel/core/ui/src/main/res/values/strings.xml @@ -11,4 +11,9 @@ 맞춤법 주술호응 + + + %1$d차 + 발화 + 대본 일치율 From eb4ed626dd202cca6761e1867a91efc0efb5a404 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 12 May 2026 01:59:26 +0900 Subject: [PATCH 10/19] =?UTF-8?q?chore:=20Detekt=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20Preview=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **chore: `detekt-config.yml` 내 무시 어노테이션 설정 확장** * `ignoreAnnotatedFunctions` 목록에 `BasicPreview` 및 `LargeDevicePreview`를 추가하여, 해당 어노테이션이 사용된 함수들이 검사 규칙(LongMethod 등)에서 제외되도록 수정했습니다. --- Prezel/detekt-config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Prezel/detekt-config.yml b/Prezel/detekt-config.yml index ac58f0dd..5589ae0d 100644 --- a/Prezel/detekt-config.yml +++ b/Prezel/detekt-config.yml @@ -42,6 +42,8 @@ complexity: thresholdInEnums: 10 ignoreAnnotatedFunctions: - Preview + - BasicPreview + - LargeDevicePreview naming: FunctionNaming: From 3de90ccbabd3a0814252bb759b6082f7cd42d881 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 12 May 2026 02:14:05 +0900 Subject: [PATCH 11/19] =?UTF-8?q?refactor:=20CardGraph=20UI=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `CardGraph` 컴포넌트 구조 및 레이아웃 최적화** * `DetailContainer`를 가로 스크롤 영역 외부로 분리하여 항목 선택 시 상세 정보가 항상 화면에 보이도록 개선했습니다. * `CardGraphContainer`의 `content` 파라미터에 `ColumnScope`를 적용하고, `Modifier.then()`을 사용하여 스타일 적용 로직을 간결화했습니다. * `XAxisRow`에서 데이터가 1개인 경우 중앙 정렬(`Arrangement.Center`)이 적용되도록 수정했습니다. * **refactor: 그래프 연산 및 그리기 로직 관심사 분리** * **`CardGraphMath`**: 좌표 계산, 최접점 인덱스 찾기 등 수학적 연산 로직을 별도 객체로 분리했습니다. * **`CardGraphDrawers`**: Canvas 그리기 로직을 가이드라인, 베이스라인, 시리즈, 마커 등으로 세분화하여 구조화했습니다. * **`CardGraphTextFormatter`**: 퍼센트 및 퍼센트 포인트 표시 등 상세 정보 텍스트 포맷팅 로직을 캡슐화했습니다. * **feat: 단일 데이터 포인트 시각화 지원** * 데이터가 하나만 존재하여 선(Line)을 그릴 수 없는 경우에도 그래프상에 해당 지점을 확인할 수 있도록 `drawMarkerIfSinglePoint` 로직을 추가했습니다. * **cleanup: 프리뷰 데이터 및 케이스 정비** * 단일 항목, 7개 항목, 10개 항목 등 다양한 데이터셋에 대한 프리뷰를 추가하고, 중복되거나 불필요한 프리뷰 구성을 정리했습니다. --- .../core/ui/component/graph/CardGraph.kt | 690 ++++++++++-------- 1 file changed, 380 insertions(+), 310 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt index d70ae0da..12f5f6bd 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -141,8 +142,15 @@ fun CardGraph( xAxisCenters[index] = center }, onSelectItem = onSelectItem, - showDetail = showDetail, + modifier = Modifier.weight(1f, fill = false), ) + + if (showDetail) { + DetailContainer( + items = items, + selectedItemIndex = uiState.selectedItemIndex, + ) + } } } } @@ -151,26 +159,27 @@ fun CardGraph( private fun CardGraphContainer( useContainerStyle: Boolean, modifier: Modifier = Modifier, - content: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit, ) { - val baseModifier = modifier - .fillMaxWidth() - .aspectRatio(CARD_GRAPH_ASPECT_RATIO) - val containerModifier = if (useContainerStyle) { - baseModifier - .clip(PrezelTheme.shapes.V8) - .background(color = PrezelTheme.colors.bgMedium) - .padding( - vertical = PrezelTheme.spacing.V12, - horizontal = PrezelTheme.spacing.V16, - ) - } else { - baseModifier - } - - Column(modifier = containerModifier) { - content() - } + Column( + modifier = modifier + .fillMaxWidth() + .aspectRatio(CARD_GRAPH_ASPECT_RATIO) + .then( + if (useContainerStyle) { + Modifier + .clip(PrezelTheme.shapes.V8) + .background(color = PrezelTheme.colors.bgMedium) + .padding( + vertical = PrezelTheme.spacing.V12, + horizontal = PrezelTheme.spacing.V16, + ) + } else { + Modifier + }, + ), + content = content, + ) } @Composable @@ -179,47 +188,37 @@ private fun CardGraphContent( uiState: CardGraphUiState, onChangePosition: (index: Int, center: Float) -> Unit, onSelectItem: (Int) -> Unit, - showDetail: Boolean, + modifier: Modifier = Modifier, ) { - Column { - Column( + Column( + modifier = modifier + .then( + if (uiState.enableScroll) { + Modifier.horizontalScroll( + state = rememberScrollState(), + overscrollEffect = null, + ) + } else { + Modifier + }, + ), + ) { + LinearChart( + items = items, + uiState = uiState, + onSelectItem = onSelectItem, modifier = Modifier - .weight(1f, fill = false) - .then( - if (uiState.enableScroll) { - Modifier.horizontalScroll( - state = rememberScrollState(), - overscrollEffect = null, - ) - } else { - Modifier - }, - ), - ) { - LinearChart( - items = items, - uiState = uiState, - onSelectItem = onSelectItem, - modifier = Modifier - .width(uiState.contentWidth) - .weight(1f, fill = false), - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) - XAxisRow( - size = items.size, - onSelectItem = onSelectItem, - modifier = Modifier.width(uiState.contentWidth), - onChangePosition = onChangePosition, - ) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) - } - - if (showDetail) { - DetailContainer( - items = items, - selectedItemIndex = uiState.selectedItemIndex, - ) - } + .width(uiState.contentWidth) + .weight(1f, fill = false), + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) + XAxisRow( + size = items.size, + onSelectItem = onSelectItem, + modifier = Modifier.width(uiState.contentWidth), + onChangePosition = onChangePosition, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) } } @@ -236,74 +235,26 @@ private fun LinearChart( Box( modifier = modifier.pointerInput(uiState.xAxisCenters, items.size) { detectTapGestures { tapOffset -> - uiState.xAxisCenters.findClosestIndex(tapOffset.x)?.let(onSelectItem) + with(CardGraphMath) { + uiState.xAxisCenters.findClosestIndex(tapOffset.x)?.let(onSelectItem) + } } }, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val chartState = items.toChartState( - xAxisCenters = uiState.xAxisCenters, - chartHeight = size.height, - selectedItemIndex = uiState.selectedItemIndex, - ) - - chartState.validXAxisCenters.forEach { centerX -> - drawLine( - color = colors.dash, - start = Offset(x = centerX, y = 0f), - end = Offset(x = centerX, y = size.height), - strokeWidth = CHART_DASH_STROKE_WIDTH, - cap = StrokeCap.Butt, - pathEffect = PathEffect.dashPathEffect( - intervals = floatArrayOf(1f, 2f), - ), - ) - } - - drawLine( - color = colors.baseline, - start = Offset(x = 0f, y = chartState.baselineY), - end = Offset(x = size.width, y = chartState.baselineY), - strokeWidth = CHART_BASELINE_STROKE_WIDTH, - ) - - if (chartState.selectedItemIndex != null) { - drawSelectedGuide( + val chartState = with(CardGraphMath) { + items.toChartState( xAxisCenters = uiState.xAxisCenters, - selectedIndex = chartState.selectedItemIndex, - color = colors.selectedGuide, - baselineY = chartState.baselineY, - strokeWidth = dimensions.selectedGuideStrokeWidthPx, - triangleWidth = dimensions.selectedTriangleWidthPx, - triangleHeight = dimensions.selectedTriangleHeightPx, + chartHeight = size.height, + selectedItemIndex = uiState.selectedItemIndex, ) } - - drawSeriesLine( - points = chartState.seriesPoints.speech, - color = colors.speech, - strokeWidth = dimensions.lineStrokeWidthPx, - ) - drawSeriesLine( - points = chartState.seriesPoints.scriptMatch, - color = colors.scriptMatch, - strokeWidth = dimensions.lineStrokeWidthPx, - ) - - if (chartState.selectedItemIndex != null) { - drawSelectedMarker( - points = chartState.seriesPoints.speech, - selectedIndex = chartState.selectedItemIndex, - color = colors.speech, - outerRadius = dimensions.outerDotRadiusPx, - innerRadius = dimensions.innerDotRadiusPx, - ) - drawSelectedMarker( - points = chartState.seriesPoints.scriptMatch, - selectedIndex = chartState.selectedItemIndex, - color = colors.scriptMatch, - outerRadius = dimensions.outerDotRadiusPx, - innerRadius = dimensions.innerDotRadiusPx, + with(CardGraphDrawers) { + drawChart( + chartState = chartState, + xAxisCenters = uiState.xAxisCenters, + colors = colors, + dimensions = dimensions, ) } } @@ -317,9 +268,11 @@ private fun XAxisRow( modifier: Modifier = Modifier, onChangePosition: (index: Int, center: Float) -> Unit, ) { + val horizontalArrangement = if (size == 1) Arrangement.Center else Arrangement.SpaceBetween + Row( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = horizontalArrangement, ) { repeat(size) { index -> Text( @@ -356,10 +309,12 @@ private fun DetailContainer( LegendItem( label = stringResource(R.string.core_ui_impl_card_graph_speech_label), color = PrezelTheme.colors.feedbackGoodRegular, - valueText = items.toDetailValueText( - selectedItemIndex = selectedItemIndex, - valueSelector = CardGraphItem::speech, - ), + valueText = with(CardGraphTextFormatter) { + items.toDetailValueText( + selectedItemIndex = selectedItemIndex, + valueSelector = CardGraphItem::speech, + ) + }, modifier = Modifier.weight(1f), ) PrezelVerticalDivider( @@ -370,10 +325,12 @@ private fun DetailContainer( LegendItem( label = stringResource(R.string.core_ui_impl_card_graph_script_match_label), color = PrezelTheme.colors.feedbackWarningRegular, - valueText = items.toDetailValueText( - selectedItemIndex = selectedItemIndex, - valueSelector = CardGraphItem::scriptMatch, - ), + valueText = with(CardGraphTextFormatter) { + items.toDetailValueText( + selectedItemIndex = selectedItemIndex, + valueSelector = CardGraphItem::scriptMatch, + ) + }, modifier = Modifier.weight(1f), ) } @@ -418,26 +375,6 @@ private fun LegendItem( } } -private fun List.toDetailValueText( - selectedItemIndex: Int?, - valueSelector: (CardGraphItem) -> Float, -): String { - val selectedItem = selectedItemIndex?.let(::getOrNull) - return if (selectedItem != null) { - selectedItem.toPercentText(valueSelector) - } else { - valueSelector(last()).minus(valueSelector(first())).toPercentPointText() - } -} - -private fun CardGraphItem.toPercentText(valueSelector: (CardGraphItem) -> Float): String = "${(valueSelector(this).coerceIn(0f, 1f) * 100).toInt()}%" - -private fun Float.toPercentPointText(): String { - val value = (this * 100).toInt() - val prefix = if (value > 0) "+" else "" - return "${prefix}$value%p" -} - @Composable private fun cardGraphColors(): CardGraphColors = CardGraphColors( @@ -483,141 +420,304 @@ private fun Dp.toChartContentWidth(itemCount: Int): Dp = this } -private fun List.validCenters(): List = mapNotNull(Float::validCenterOrNull) +private object CardGraphTextFormatter { + fun List.toDetailValueText( + selectedItemIndex: Int?, + valueSelector: (CardGraphItem) -> Float, + ): String { + val selectedItem = selectedItemIndex?.let(::getOrNull) + return if (selectedItem != null) { + selectedItem.toPercentText(valueSelector) + } else if (size == 1) { + first().toPercentText(valueSelector) + } else { + valueSelector(last()).minus(valueSelector(first())).toPercentPointText() + } + } -private fun Float.validCenterOrNull(): Float? = takeUnless(Float::isNaN) + private fun CardGraphItem.toPercentText(valueSelector: (CardGraphItem) -> Float): String = + "${(valueSelector(this).coerceIn(0f, 1f) * 100).toInt()}%" -private fun List.toChartState( - xAxisCenters: List, - chartHeight: Float, - selectedItemIndex: Int?, -): CardGraphChartState { - val baselineY = chartHeight - (CHART_BASELINE_STROKE_WIDTH / 2f) - return CardGraphChartState( - baselineY = baselineY, - seriesPoints = toSeriesPoints( - xAxisCenters = xAxisCenters, - chartHeight = baselineY, - ), - validXAxisCenters = xAxisCenters.validCenters(), - selectedItemIndex = selectedItemIndex, - ) + private fun Float.toPercentPointText(): String { + val value = (this * 100).toInt() + val prefix = if (value > 0) "+" else "" + return "${prefix}$value%p" + } } -private fun List.toSeriesPoints( - xAxisCenters: List, - chartHeight: Float, -): CardGraphSeriesPoints = - CardGraphSeriesPoints( - speech = mapSeriesPoints( - xAxisCenters = xAxisCenters, - chartHeight = chartHeight, - valueSelector = CardGraphItem::speech, - ), - scriptMatch = mapSeriesPoints( - xAxisCenters = xAxisCenters, - chartHeight = chartHeight, - valueSelector = CardGraphItem::scriptMatch, - ), - ) +private object CardGraphMath { + fun List.toChartState( + xAxisCenters: List, + chartHeight: Float, + selectedItemIndex: Int?, + ): CardGraphChartState { + val baselineY = chartHeight - (CHART_BASELINE_STROKE_WIDTH / 2f) + return CardGraphChartState( + baselineY = baselineY, + seriesPoints = toSeriesPoints( + xAxisCenters = xAxisCenters, + chartHeight = baselineY, + ), + validXAxisCenters = xAxisCenters.validCenters(), + selectedItemIndex = selectedItemIndex, + ) + } -private fun List.mapSeriesPoints( - xAxisCenters: List, - chartHeight: Float, - valueSelector: (CardGraphItem) -> Float, -): List = - mapIndexedNotNull { index, item -> - xAxisCenters - .getOrNull(index) - ?.validCenterOrNull() - ?.let { centerX -> - Offset( - x = centerX, - y = chartHeight - (valueSelector(item).coerceIn(0f, 1f) * chartHeight), - ) - } + fun List.findClosestIndex(targetX: Float): Int? = + mapIndexedNotNull { index, centerX -> + centerX.validCenterOrNull()?.let { index to kotlin.math.abs(it - targetX) } + }.minByOrNull { (_, distance) -> distance } + ?.first + + private fun List.validCenters(): List = mapNotNull { it.validCenterOrNull() } + + private fun Float.validCenterOrNull(): Float? = takeUnless(Float::isNaN) + + private fun List.toSeriesPoints( + xAxisCenters: List, + chartHeight: Float, + ): CardGraphSeriesPoints = + CardGraphSeriesPoints( + speech = mapSeriesPoints( + xAxisCenters = xAxisCenters, + chartHeight = chartHeight, + valueSelector = CardGraphItem::speech, + ), + scriptMatch = mapSeriesPoints( + xAxisCenters = xAxisCenters, + chartHeight = chartHeight, + valueSelector = CardGraphItem::scriptMatch, + ), + ) + + private fun List.mapSeriesPoints( + xAxisCenters: List, + chartHeight: Float, + valueSelector: (CardGraphItem) -> Float, + ): List = + mapIndexedNotNull { index, item -> + xAxisCenters + .getOrNull(index) + ?.validCenterOrNull() + ?.let { centerX -> + Offset( + x = centerX, + y = chartHeight - (valueSelector(item).coerceIn(0f, 1f) * chartHeight), + ) + } + } +} + +private object CardGraphDrawers { + fun DrawScope.drawChart( + chartState: CardGraphChartState, + xAxisCenters: List, + colors: CardGraphColors, + dimensions: CardGraphDimensions, + ) { + drawVerticalGuides(chartState.validXAxisCenters, colors.dash) + drawBaseline(chartState.baselineY, colors.baseline) + drawSelectionGuide(chartState, xAxisCenters, colors, dimensions) + drawSeries(chartState, colors, dimensions) + drawSinglePointMarkers(chartState, colors, dimensions) + drawSelectionMarkers(chartState, colors, dimensions) } -private fun DrawScope.drawSeriesLine( - points: List, - color: Color, - strokeWidth: Float, -) { - if (points.size < 2) return + private fun DrawScope.drawVerticalGuides( + validXAxisCenters: List, + color: Color, + ) { + validXAxisCenters.forEach { centerX -> + drawLine( + color = color, + start = Offset(x = centerX, y = 0f), + end = Offset(x = centerX, y = size.height), + strokeWidth = CHART_DASH_STROKE_WIDTH, + cap = StrokeCap.Butt, + pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(1f, 2f), + ), + ) + } + } - for (index in 0 until points.lastIndex) { + private fun DrawScope.drawBaseline( + baselineY: Float, + color: Color, + ) { drawLine( color = color, - start = points[index], - end = points[index + 1], - strokeWidth = strokeWidth, - cap = StrokeCap.Round, + start = Offset(x = 0f, y = baselineY), + end = Offset(x = size.width, y = baselineY), + strokeWidth = CHART_BASELINE_STROKE_WIDTH, ) } -} -private fun DrawScope.drawSelectedMarker( - points: List, - selectedIndex: Int, - color: Color, - outerRadius: Float, - innerRadius: Float, -) { - val point = points.getOrNull(selectedIndex) ?: return + private fun DrawScope.drawSelectionGuide( + chartState: CardGraphChartState, + xAxisCenters: List, + colors: CardGraphColors, + dimensions: CardGraphDimensions, + ) { + val selectedIndex = chartState.selectedItemIndex ?: return + drawSelectedGuide( + xAxisCenters = xAxisCenters, + selectedIndex = selectedIndex, + color = colors.selectedGuide, + baselineY = chartState.baselineY, + strokeWidth = dimensions.selectedGuideStrokeWidthPx, + triangleWidth = dimensions.selectedTriangleWidthPx, + triangleHeight = dimensions.selectedTriangleHeightPx, + ) + } - drawCircle( - color = color.copy(alpha = 0.3f), - radius = outerRadius, - center = point, - ) - drawCircle( - color = color, - radius = innerRadius, - center = point, - ) -} + private fun DrawScope.drawSeries( + chartState: CardGraphChartState, + colors: CardGraphColors, + dimensions: CardGraphDimensions, + ) { + drawSeriesLine( + points = chartState.seriesPoints.speech, + color = colors.speech, + strokeWidth = dimensions.lineStrokeWidthPx, + ) + drawSeriesLine( + points = chartState.seriesPoints.scriptMatch, + color = colors.scriptMatch, + strokeWidth = dimensions.lineStrokeWidthPx, + ) + } -private fun DrawScope.drawSelectedGuide( - xAxisCenters: List, - selectedIndex: Int, - color: Color, - baselineY: Float, - strokeWidth: Float, - triangleWidth: Float, - triangleHeight: Float, -) { - val centerX = xAxisCenters.getOrNull(selectedIndex)?.takeUnless(Float::isNaN) ?: return - val triangleApexY = baselineY - triangleHeight - - drawLine( - color = color, - start = Offset(x = centerX, y = 0f), - end = Offset(x = centerX, y = triangleApexY), - strokeWidth = strokeWidth, - cap = StrokeCap.Butt, - ) + private fun DrawScope.drawSelectionMarkers( + chartState: CardGraphChartState, + colors: CardGraphColors, + dimensions: CardGraphDimensions, + ) { + val selectedIndex = chartState.selectedItemIndex ?: return + drawSelectedMarker( + points = chartState.seriesPoints.speech, + selectedIndex = selectedIndex, + color = colors.speech, + outerRadius = dimensions.outerDotRadiusPx, + innerRadius = dimensions.innerDotRadiusPx, + ) + drawSelectedMarker( + points = chartState.seriesPoints.scriptMatch, + selectedIndex = selectedIndex, + color = colors.scriptMatch, + outerRadius = dimensions.outerDotRadiusPx, + innerRadius = dimensions.innerDotRadiusPx, + ) + } - drawPath( - path = Path().apply { - moveTo(x = centerX - (triangleWidth / 2f), y = baselineY) - lineTo(x = centerX + (triangleWidth / 2f), y = baselineY) - lineTo(x = centerX, y = triangleApexY) - close() - }, - color = color, - ) -} + private fun DrawScope.drawSinglePointMarkers( + chartState: CardGraphChartState, + colors: CardGraphColors, + dimensions: CardGraphDimensions, + ) { + if (chartState.selectedItemIndex != null) return + + drawMarkerIfSinglePoint( + points = chartState.seriesPoints.speech, + color = colors.speech, + radius = dimensions.innerDotRadiusPx, + ) + drawMarkerIfSinglePoint( + points = chartState.seriesPoints.scriptMatch, + color = colors.scriptMatch, + radius = dimensions.innerDotRadiusPx, + ) + } + + private fun DrawScope.drawSeriesLine( + points: List, + color: Color, + strokeWidth: Float, + ) { + if (points.size < 2) return + + for (index in 0 until points.lastIndex) { + drawLine( + color = color, + start = points[index], + end = points[index + 1], + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + } + + private fun DrawScope.drawSelectedMarker( + points: List, + selectedIndex: Int, + color: Color, + outerRadius: Float, + innerRadius: Float, + ) { + val point = points.getOrNull(selectedIndex) ?: return -private fun List.findClosestIndex(targetX: Float): Int? = - mapIndexedNotNull { index, centerX -> - centerX.validCenterOrNull()?.let { index to kotlin.math.abs(it - targetX) } - }.minByOrNull { (_, distance) -> distance } - ?.first + drawCircle( + color = color.copy(alpha = 0.3f), + radius = outerRadius, + center = point, + ) + drawCircle( + color = color, + radius = innerRadius, + center = point, + ) + } + + private fun DrawScope.drawMarkerIfSinglePoint( + points: List, + color: Color, + radius: Float, + ) { + if (points.size != 1) return + + drawCircle( + color = color, + radius = radius, + center = points.first(), + ) + } + + private fun DrawScope.drawSelectedGuide( + xAxisCenters: List, + selectedIndex: Int, + color: Color, + baselineY: Float, + strokeWidth: Float, + triangleWidth: Float, + triangleHeight: Float, + ) { + val centerX = xAxisCenters.getOrNull(selectedIndex)?.takeUnless(Float::isNaN) ?: return + val triangleApexY = baselineY - triangleHeight -private val cardGraphPreviewItems = persistentListOf( + drawLine( + color = color, + start = Offset(x = centerX, y = 0f), + end = Offset(x = centerX, y = triangleApexY), + strokeWidth = strokeWidth, + cap = StrokeCap.Butt, + ) + + drawPath( + path = Path().apply { + moveTo(x = centerX - (triangleWidth / 2f), y = baselineY) + lineTo(x = centerX + (triangleWidth / 2f), y = baselineY) + lineTo(x = centerX, y = triangleApexY) + close() + }, + color = color, + ) + } +} + +private val cardGraphTenItemPreviewItems = persistentListOf( CardGraphItem( - speech = 0.92f, - scriptMatch = 0.88f, + speech = 0.76f, + scriptMatch = 0.67f, ), CardGraphItem( speech = 0.75f, @@ -647,10 +747,16 @@ private val cardGraphPreviewItems = persistentListOf( speech = 0.69f, scriptMatch = 0.73f, ), + CardGraphItem( + speech = 0.77f, + scriptMatch = 0.66f, + ), + CardGraphItem( + speech = 0.88f, + scriptMatch = 0.94f, + ), ) -private val cardGraphCompactPreviewItems = cardGraphPreviewItems.take(5).toImmutableList() - @Composable private fun CardGraphPreviewContainer( items: ImmutableList, @@ -674,61 +780,25 @@ private fun CardGraphPreviewContainer( @BasicPreview @Composable -private fun CardGraphDefaultPreview() { +private fun CardGraphSingleItemPreview() { PrezelTheme { - CardGraphPreviewContainer(items = cardGraphCompactPreviewItems) + CardGraphPreviewContainer(items = cardGraphTenItemPreviewItems.take(1).toImmutableList()) } } @BasicPreview @Composable -private fun CardGraphSelectedPointPreview() { +private fun CardGraphSevenItemPreview() { PrezelTheme { - CardGraphPreviewContainer( - items = cardGraphCompactPreviewItems, - selectedItemIndex = 2, - ) + CardGraphPreviewContainer(items = cardGraphTenItemPreviewItems.take(7).toImmutableList()) } } @BasicPreview @Composable -private fun CardGraphScrollablePreview() { +private fun CardGraphTenItemPreview() { PrezelTheme { - CardGraphPreviewContainer(items = cardGraphPreviewItems) - } -} - -@BasicPreview -@Composable -private fun CardGraphScrollableSelectedPointPreview() { - PrezelTheme { - CardGraphPreviewContainer( - items = cardGraphPreviewItems, - selectedItemIndex = 6, - ) - } -} - -@BasicPreview -@Composable -private fun CardGraphWithoutDetailPreview() { - PrezelTheme { - CardGraphPreviewContainer( - items = cardGraphCompactPreviewItems, - showDetail = false, - ) - } -} - -@BasicPreview -@Composable -private fun CardGraphWithoutContainerStylePreview() { - PrezelTheme { - CardGraphPreviewContainer( - items = cardGraphCompactPreviewItems, - useContainerStyle = false, - ) + CardGraphPreviewContainer(items = cardGraphTenItemPreviewItems) } } @@ -760,7 +830,7 @@ private fun CardGraphInteractivePreview() { } CardGraph( - items = cardGraphPreviewItems, + items = cardGraphTenItemPreviewItems, selectedItemIndex = selectedItemIndex, showDetail = showDetail, useContainerStyle = useContainerStyle, From bd7a51510459f492554b953adc5342243d0e2448 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 12 May 2026 02:18:41 +0900 Subject: [PATCH 12/19] =?UTF-8?q?docs:=20CardGraphDrawers=20=EB=82=B4=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20=EB=B0=8F=20=EA=B0=80=EB=8F=85=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **docs: `CardGraphDrawers` 내 주요 그리기 메서드에 KDoc 추가** * 차트의 가이드라인, 기준선, 시리즈(데이터 선), 마커 등 각 드로잉 단계의 역할과 시각적 우선순위에 대한 설명을 추가했습니다. * 데이터가 하나인 경우의 처리(`drawMarkerIfSinglePoint`) 및 선택 상태의 가이드 표현(`drawSelectedGuide`) 등 세부 로직에 주석을 보강했습니다. * **style: `drawChart` 메서드 내 명시적 매개변수 이름 적용** * 코드 가독성을 높이기 위해 `drawChart` 함수에서 하위 드로잉 함수를 호출할 때 모든 인자에 명시적 매개변수 이름(Named Arguments)을 사용하도록 수정했습니다. --- .../core/ui/component/graph/CardGraph.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt index 12f5f6bd..93bfc9d4 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt @@ -509,20 +509,26 @@ private object CardGraphMath { } private object CardGraphDrawers { + /** + * 카드 그래프의 가이드, 선, 마커를 순서대로 그린다. + * + * 배경성 요소를 먼저 그리고 데이터 요소를 마지막에 올려 시각적 우선순위를 유지한다. + */ fun DrawScope.drawChart( chartState: CardGraphChartState, xAxisCenters: List, colors: CardGraphColors, dimensions: CardGraphDimensions, ) { - drawVerticalGuides(chartState.validXAxisCenters, colors.dash) - drawBaseline(chartState.baselineY, colors.baseline) - drawSelectionGuide(chartState, xAxisCenters, colors, dimensions) - drawSeries(chartState, colors, dimensions) - drawSinglePointMarkers(chartState, colors, dimensions) - drawSelectionMarkers(chartState, colors, dimensions) + drawVerticalGuides(validXAxisCenters = chartState.validXAxisCenters, color = colors.dash) + drawBaseline(baselineY = chartState.baselineY, color = colors.baseline) + drawSelectionGuide(chartState = chartState, xAxisCenters = xAxisCenters, colors = colors, dimensions = dimensions) + drawSeries(chartState = chartState, colors = colors, dimensions = dimensions) + drawSinglePointMarkers(chartState = chartState, colors = colors, dimensions = dimensions) + drawSelectionMarkers(chartState = chartState, colors = colors, dimensions = dimensions) } + /** x축 레이블 중심 좌표를 기준으로 세로 가이드를 그린다. */ private fun DrawScope.drawVerticalGuides( validXAxisCenters: List, color: Color, @@ -541,6 +547,7 @@ private object CardGraphDrawers { } } + /** 차트 영역의 하단 기준선과 x축 경계를 그린다. */ private fun DrawScope.drawBaseline( baselineY: Float, color: Color, @@ -553,6 +560,7 @@ private object CardGraphDrawers { ) } + /** 선택된 인덱스가 있을 때 해당 x축 위치의 세로 가이드를 그린다. */ private fun DrawScope.drawSelectionGuide( chartState: CardGraphChartState, xAxisCenters: List, @@ -571,6 +579,7 @@ private object CardGraphDrawers { ) } + /** 발화와 대본 일치율 시리즈 선을 각각 그린다. */ private fun DrawScope.drawSeries( chartState: CardGraphChartState, colors: CardGraphColors, @@ -588,6 +597,7 @@ private object CardGraphDrawers { ) } + /** 선택된 인덱스의 두 시리즈 포인트를 강조 마커로 그린다. */ private fun DrawScope.drawSelectionMarkers( chartState: CardGraphChartState, colors: CardGraphColors, @@ -629,6 +639,7 @@ private object CardGraphDrawers { ) } + /** 인접한 좌표들을 직선으로 이어 시리즈 선을 만든다. */ private fun DrawScope.drawSeriesLine( points: List, color: Color, @@ -647,6 +658,7 @@ private object CardGraphDrawers { } } + /** 선택된 포인트 위에 halo와 중심점을 함께 그린다. */ private fun DrawScope.drawSelectedMarker( points: List, selectedIndex: Int, @@ -668,6 +680,7 @@ private object CardGraphDrawers { ) } + /** 데이터가 1개뿐일 때 선택 상태 없이도 포인트가 보이도록 점을 그린다. */ private fun DrawScope.drawMarkerIfSinglePoint( points: List, color: Color, @@ -682,6 +695,7 @@ private object CardGraphDrawers { ) } + /** 선택된 x축 위치에 세로 가이드와 삼각형 포인터를 그린다. */ private fun DrawScope.drawSelectedGuide( xAxisCenters: List, selectedIndex: Int, From 7b83b23f07d761f10192a5d593a1a912509c509f Mon Sep 17 00:00:00 2001 From: moondev03 Date: Wed, 13 May 2026 22:37:16 +0900 Subject: [PATCH 13/19] =?UTF-8?q?refactor:=20SpeedGraph=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EB=B0=8F=20=EA=B0=80=EB=8F=85=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `SpeedGraph` 내 그리기 로직 분리** * `onDrawBehind` 블록 내부에 직접 작성되어 있던 `drawArc` 코드들을 기능별로 모듈화했습니다. * `DrawScope`의 private 확장 함수인 `drawBaseTrack`, `drawGoodRange`, `drawUserGauge`를 추가하여 각 그래픽 레이어의 역할을 명확히 정의했습니다. * 기존의 복잡한 매개변수 전달 구조를 유지하면서도, 함수 추출을 통해 코드의 가독성과 유지보수성을 높였습니다. --- .../core/ui/component/graph/SpeedGraph.kt | 97 ++++++++++++++----- 1 file changed, 73 insertions(+), 24 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt index 824d69b0..ceb6a239 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.preview.BasicPreview @@ -139,42 +140,90 @@ private fun SpeedGaugeArc( val (arcTopLeft: Offset, arcSize: Size, arcStroke: Stroke) = size.calculateGraphArcMetrics() onDrawBehind { - drawArc( - color = colors.baseTrackColor, - startAngle = START_ANGLE, - sweepAngle = FULL_CIRCLE_DEGREES, - useCenter = false, - topLeft = arcTopLeft, - size = arcSize, - style = arcStroke, + drawBaseTrack( + trackColor = colors.baseTrackColor, + arcTopLeft = arcTopLeft, + arcSize = arcSize, + arcStroke = arcStroke, ) if (!clampedGoodRange.isEmpty()) { - drawArc( - color = colors.goodRangeColor, - startAngle = goodRangeStartAngle, - sweepAngle = goodRangeSweepAngle, - useCenter = false, - topLeft = arcTopLeft, - size = arcSize, - style = arcStroke, + drawGoodRange( + rangeColor = colors.goodRangeColor, + goodRangeStartAngle = goodRangeStartAngle, + goodRangeSweepAngle = goodRangeSweepAngle, + arcTopLeft = arcTopLeft, + arcSize = arcSize, + arcStroke = arcStroke, ) } - drawArc( - color = userGaugeColor, - startAngle = START_ANGLE, - sweepAngle = userGaugeSweepAngle, - useCenter = false, - topLeft = arcTopLeft, - size = arcSize, - style = arcStroke, + drawUserGauge( + userGaugeColor = userGaugeColor, + userGaugeSweepAngle = userGaugeSweepAngle, + arcTopLeft = arcTopLeft, + arcSize = arcSize, + arcStroke = arcStroke, ) } }, ) } +private fun DrawScope.drawBaseTrack( + trackColor: Color, + arcTopLeft: Offset, + arcSize: Size, + arcStroke: Stroke, +) { + drawArc( + color = trackColor, + startAngle = START_ANGLE, + sweepAngle = FULL_CIRCLE_DEGREES, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = arcStroke, + ) +} + +private fun DrawScope.drawGoodRange( + rangeColor: Color, + goodRangeStartAngle: Float, + goodRangeSweepAngle: Float, + arcTopLeft: Offset, + arcSize: Size, + arcStroke: Stroke, +) { + drawArc( + color = rangeColor, + startAngle = goodRangeStartAngle, + sweepAngle = goodRangeSweepAngle, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = arcStroke, + ) +} + +private fun DrawScope.drawUserGauge( + userGaugeColor: Color, + userGaugeSweepAngle: Float, + arcTopLeft: Offset, + arcSize: Size, + arcStroke: Stroke, +) { + drawArc( + color = userGaugeColor, + startAngle = START_ANGLE, + sweepAngle = userGaugeSweepAngle, + useCenter = false, + topLeft = arcTopLeft, + size = arcSize, + style = arcStroke, + ) +} + @Composable private fun SpeedValueLabel(userGauge: Int) { Column(horizontalAlignment = Alignment.CenterHorizontally) { From f314efb382c5dfea8b5ff63256f09ea12afb3279 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Thu, 14 May 2026 02:12:04 +0900 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20PracticeCard=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=97=B0?= =?UTF-8?q?=EC=8A=B5=20=ED=98=84=ED=99=A9=20=ED=8A=B8=EB=9E=98=EC=BB=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 연습 진행 상황을 시각화하는 `PracticeCard` 컴포넌트 신규 개발** * 날짜별 연습 완료 여부를 스탬프 형태로 표시하는 트래커 기능을 구현했습니다. * 7일 단위의 페이징 처리를 지원하여 과거 및 향후 연습 계획을 확인할 수 있습니다. * 연습 상태(`AFTER`, `BEFORE`, `EMPTY`)에 따라 배경색, 아이콘, 텍스트 색상 및 대시 보드 스타일이 동적으로 변경되도록 설계했습니다. * **feat: `PracticeCard` 내 세부 UI 요소 및 데이터 모델 정의** * 전체 연습 횟수 대비 완료 횟수를 표시하는 `CountRow`를 추가했습니다. * 연습하기 동작을 수행하는 `PrezelTextButton` 기반의 액션 버튼을 포함했습니다. * 외부에서 UI 상태를 주입받기 위한 `PracticeCardItem` 데이터 모델을 정의했습니다. --- Prezel/core/ui/build.gradle.kts | 1 + .../prezel/core/ui/component/PracticeCard.kt | 357 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt diff --git a/Prezel/core/ui/build.gradle.kts b/Prezel/core/ui/build.gradle.kts index fbc9f29d..5a2f1151 100644 --- a/Prezel/core/ui/build.gradle.kts +++ b/Prezel/core/ui/build.gradle.kts @@ -11,4 +11,5 @@ dependencies { implementation(projects.coreModel) implementation(libs.lottie.compose) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) } diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt new file mode 100644 index 00000000..69f64a0a --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt @@ -0,0 +1,357 @@ +package com.team.prezel.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelTextButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.designsystem.util.drawDashBorder +import com.team.prezel.core.ui.util.noRippleClickable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil +import kotlinx.datetime.format +import kotlinx.datetime.format.DateTimeFormat +import kotlinx.datetime.plus + +private const val TRACKER_SIZE = 7 +private val StampFormatter: DateTimeFormat = LocalDate.Format { day() } + +private enum class StampType { + AFTER, + BEFORE, + EMPTY, +} + +@Immutable +private data class TrackerItem( + val date: LocalDate, + val isPracticed: Boolean, + val type: StampType, +) + +@Immutable +data class PracticeCardItem( + val date: LocalDate, + val isPracticed: Boolean, +) + +@Composable +fun PracticeCard( + dDay: LocalDate, + items: ImmutableList, + modifier: Modifier = Modifier, + showActionButton: Boolean = true, + onClickAction: () -> Unit = {}, +) { + var startIndex by rememberSaveable(items) { mutableIntStateOf(0) } + val trackerItems = items.toTrackerItems(dDay = dDay) + val hasPreviousPage = startIndex > 0 + val hasNextPage = startIndex + TRACKER_SIZE < trackerItems.size + + Column( + modifier = modifier + .fillMaxWidth() + .clip(PrezelTheme.shapes.V6) + .background(color = PrezelTheme.colors.bgMedium) + .padding( + vertical = PrezelTheme.spacing.V12, + horizontal = PrezelTheme.spacing.V16, + ), + ) { + PracticeCardHeader( + practicedCount = items.count { item -> item.isPracticed }, + totalCount = items.size, + hasNextPage = hasNextPage, + hasPreviousPage = hasPreviousPage, + onClickLeft = { if (hasPreviousPage) startIndex -= TRACKER_SIZE }, + onClickRight = { if (hasNextPage) startIndex += TRACKER_SIZE }, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + + PracticeTracker( + items = trackerItems, + startIndex = startIndex, + ) + + if (showActionButton) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + + PrezelTextButton( + text = "연습하기", + type = ButtonType.FILLED, + size = ButtonSize.SMALL, + hierarchy = ButtonHierarchy.SECONDARY, + onClick = onClickAction, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun PracticeCardHeader( + practicedCount: Int, + totalCount: Int, + hasNextPage: Boolean, + hasPreviousPage: Boolean, + onClickLeft: () -> Unit, + onClickRight: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PaginationRow( + showChevron = totalCount > TRACKER_SIZE, + hasNextPage = hasNextPage, + hasPreviousPage = hasPreviousPage, + onClickLeft = onClickLeft, + onClickRight = onClickRight, + ) + + CountRow( + practicedCount = practicedCount, + totalCount = totalCount, + ) + } +} + +@Composable +private fun PaginationRow( + showChevron: Boolean, + hasNextPage: Boolean, + hasPreviousPage: Boolean, + onClickLeft: () -> Unit, + onClickRight: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showChevron) { + Icon( + painter = painterResource(PrezelIcons.ChevronLeft), + contentDescription = "이전 페이지", + tint = chevronIconTintColor(enabled = hasPreviousPage), + modifier = Modifier.noRippleClickable(onClickLeft), + ) + } + + Text( + text = "연습한 횟수", + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + ) + + if (showChevron) { + Icon( + painter = painterResource(PrezelIcons.ChevronRight), + contentDescription = "다음 페이지", + tint = chevronIconTintColor(enabled = hasNextPage), + modifier = Modifier.noRippleClickable(onClickRight), + ) + } + } +} + +@Composable +private fun chevronIconTintColor(enabled: Boolean): Color = if (enabled) PrezelTheme.colors.iconRegular else PrezelTheme.colors.iconDisabled + +@Composable +private fun CountRow( + practicedCount: Int, + totalCount: Int, + modifier: Modifier = Modifier, +) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(color = PrezelTheme.colors.textMedium)) { + append(practicedCount.toString()) + } + withStyle(style = SpanStyle(color = PrezelTheme.colors.textSmall)) { + append("/") + append(totalCount.toString()) + } + }, + style = PrezelTheme.typography.body3Medium, + modifier = modifier, + ) +} + +@Composable +private fun PracticeTracker( + items: ImmutableList, + startIndex: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + items + .drop(startIndex) + .take(TRACKER_SIZE) + .forEach { item -> + PracticeStamp(date = item.date, type = item.type) + } + } +} + +private fun ImmutableList.toTrackerItems(dDay: LocalDate): ImmutableList { + val blankCount = (TRACKER_SIZE - (size % TRACKER_SIZE)) + .takeIf { count -> count != TRACKER_SIZE } ?: 0 + + val lastDate = lastOrNull()?.date ?: dDay + + val blankItems = List(blankCount) { index -> + PracticeCardItem( + date = lastDate.plus(DatePeriod(days = index + 1)), + isPracticed = false, + ) + } + + return (this + blankItems) + .map { item -> + TrackerItem( + date = item.date, + isPracticed = item.isPracticed, + type = when { + item.date >= dDay -> StampType.EMPTY + item.isPracticed -> StampType.AFTER + else -> StampType.BEFORE + }, + ) + }.toImmutableList() +} + +@Composable +private fun PracticeStamp( + date: LocalDate, + type: StampType, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = date.format(StampFormatter), + style = PrezelTheme.typography.caption2Regular, + color = type.textColor(), + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V2)) + + Box( + modifier = Modifier + .size(32.dp) + .clip(PrezelTheme.shapes.V1000) + .background(color = type.bgColor()) + .applyDashBorder(type = type), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(PrezelIcons.Check), + contentDescription = "", + tint = type.tintColor(), + ) + } + } +} + +@Composable +private fun StampType.textColor(): Color = + when (this) { + StampType.EMPTY -> PrezelTheme.colors.textDisabled + StampType.AFTER, + StampType.BEFORE, + -> PrezelTheme.colors.textRegular + } + +@Composable +private fun StampType.bgColor(): Color = + when (this) { + StampType.AFTER -> PrezelTheme.colors.interactiveRegular + StampType.BEFORE -> PrezelTheme.colors.bgLarge + StampType.EMPTY -> PrezelTheme.colors.bgDisabled + } + +@Composable +private fun StampType.tintColor(): Color = + when (this) { + StampType.AFTER -> PrezelTheme.colors.solidWhite + StampType.BEFORE -> PrezelTheme.colors.iconDisabled + StampType.EMPTY -> Color.Transparent + } + +@Composable +private fun Modifier.applyDashBorder(type: StampType): Modifier { + if (type != StampType.BEFORE) return this + val density = LocalDensity.current + + return drawDashBorder( + shape = PrezelTheme.shapes.V1000, + color = PrezelTheme.colors.borderMedium, + width = with(density) { 1.dp.toPx() }, + interval = with(density) { 2.dp.toPx() }, + ) +} + +@BasicPreview +@Composable +private fun PracticeCardPreview() { + val baseDate = LocalDate(year = 2026, month = 3, day = 20) + val dDay = LocalDate(year = 2026, month = 3, day = 30) + + PrezelTheme { + Box(modifier = Modifier.padding(16.dp)) { + PracticeCard( + dDay = dDay, + items = List(baseDate.daysUntil(dDay)) { index -> + PracticeCardItem( + date = baseDate.plus(DatePeriod(days = index)), + isPracticed = index % 2 == 0, + ) + }.toPersistentList(), + ) + } + } +} From 035082c1f655281d7fb58b1d1c32c350be784978 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 18 May 2026 19:47:45 +0900 Subject: [PATCH 15/19] =?UTF-8?q?refactor:=20`CardGraph`=20UI=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `CardGraphUiState` 안정성 최적화** * `CardGraphUiState` 클래스에 `@Immutable` 어노테이션을 추가하여 Compose의 재구성(Recomposition) 성능을 최적화했습니다. * `xAxisCenters` 필드의 타입을 `ImmutableList`에서 `List`로 변경하고, 이에 따른 `toImmutableList()` 변환 로직을 제거했습니다. * **refactor: `pointerInput` 이벤트 처리 로직 개선** * `Box` 컴포넌트의 `pointerInput`에서 불필요한 `uiState.xAxisCenters` 종속성을 제거하고 `items.size`만을 키로 사용하도록 수정했습니다. --- .../com/team/prezel/core/ui/component/graph/CardGraph.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt index 93bfc9d4..16d2d7e8 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -69,10 +70,11 @@ private val CHART_SELECTED_TRIANGLE_WIDTH = 6.dp private val CHART_SELECTED_TRIANGLE_HEIGHT = 6.dp private const val CARD_GRAPH_ASPECT_RATIO = 1.5f +@Immutable private data class CardGraphUiState( val enableScroll: Boolean, val contentWidth: Dp, - val xAxisCenters: ImmutableList, + val xAxisCenters: List, val selectedItemIndex: Int?, ) @@ -127,7 +129,7 @@ fun CardGraph( val uiState = CardGraphUiState( enableScroll = items.shouldEnableHorizontalScroll(), contentWidth = maxWidth.toChartContentWidth(itemCount = items.size), - xAxisCenters = xAxisCenters.toImmutableList(), + xAxisCenters = xAxisCenters, selectedItemIndex = items.resolveSelectedItemIndex(selectedItemIndex), ) @@ -233,7 +235,7 @@ private fun LinearChart( val dimensions = rememberCardGraphDimensions() Box( - modifier = modifier.pointerInput(uiState.xAxisCenters, items.size) { + modifier = modifier.pointerInput(items.size) { detectTapGestures { tapOffset -> with(CardGraphMath) { uiState.xAxisCenters.findClosestIndex(tapOffset.x)?.let(onSelectItem) From 06aa8a764229fa47aa965c8c2d606afae4258319 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 18 May 2026 19:48:46 +0900 Subject: [PATCH 16/19] =?UTF-8?q?refactor:=20StickGraph=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `toStickHeight` 함수 내 0 나누기 방지 로직 추가** * `maxCount`가 0 이하인 경우 `0.dp`를 반환하도록 예외 처리를 추가하여 비정상적인 높이 계산 및 잠재적인 런타임 에러를 방지했습니다. * **style: StickGraph 컴포넌트 내 Modifier 포맷팅 수정** * 코드 가독성 향상을 위해 Modifier 체이닝의 줄바꿈 형식을 일관성 있게 수정했습니다. --- .../java/com/team/prezel/core/ui/component/graph/StickGraph.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt index 3f732e6f..1ded9567 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt @@ -120,6 +120,8 @@ private fun ImmutableList.aggregateByItemType(): ImmutableList Date: Mon, 18 May 2026 19:52:41 +0900 Subject: [PATCH 17/19] =?UTF-8?q?refactor:=20PracticeCard=20=EB=82=B4=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=EB=90=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=EC=9D=84=20=EB=A6=AC=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `PracticeCard` 컴포넌트 내 문자열 리소스 적용** * "연습하기", "연습한 횟수" 등의 텍스트와 아이콘의 `contentDescription`("이전 페이지", "다음 페이지")에 사용되던 하드코딩 문자열을 `stringResource`를 사용하도록 수정했습니다. * **feat: `core:ui` 모듈 내 문자열 리소스 추가** * `strings.xml`에 연습 카드 관련 액션 및 내비게이션 설명을 위한 리소스(`core_ui_impl_practice_card_action`, `core_ui_impl_practice_card_count_label` 등)를 정의했습니다. --- .../team/prezel/core/ui/component/PracticeCard.kt | 12 +++++++----- Prezel/core/ui/src/main/res/values/strings.xml | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt index 69f64a0a..cf1c6f3f 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle @@ -36,6 +37,7 @@ import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.designsystem.util.drawDashBorder +import com.team.prezel.core.ui.R import com.team.prezel.core.ui.util.noRippleClickable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -112,7 +114,7 @@ fun PracticeCard( Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) PrezelTextButton( - text = "연습하기", + text = stringResource(R.string.core_ui_impl_practice_card_action), type = ButtonType.FILLED, size = ButtonSize.SMALL, hierarchy = ButtonHierarchy.SECONDARY, @@ -170,14 +172,14 @@ private fun PaginationRow( if (showChevron) { Icon( painter = painterResource(PrezelIcons.ChevronLeft), - contentDescription = "이전 페이지", + contentDescription = stringResource(R.string.core_ui_impl_practice_card_prev_page), tint = chevronIconTintColor(enabled = hasPreviousPage), modifier = Modifier.noRippleClickable(onClickLeft), ) } Text( - text = "연습한 횟수", + text = stringResource(R.string.core_ui_impl_practice_card_count_label), style = PrezelTheme.typography.body3Medium, color = PrezelTheme.colors.textMedium, ) @@ -185,7 +187,7 @@ private fun PaginationRow( if (showChevron) { Icon( painter = painterResource(PrezelIcons.ChevronRight), - contentDescription = "다음 페이지", + contentDescription = stringResource(R.string.core_ui_impl_practice_card_next_page), tint = chevronIconTintColor(enabled = hasNextPage), modifier = Modifier.noRippleClickable(onClickRight), ) @@ -290,7 +292,7 @@ private fun PracticeStamp( ) { Icon( painter = painterResource(PrezelIcons.Check), - contentDescription = "", + contentDescription = null, tint = type.tintColor(), ) } diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index 2ab7d6a4..32b5a672 100644 --- a/Prezel/core/ui/src/main/res/values/strings.xml +++ b/Prezel/core/ui/src/main/res/values/strings.xml @@ -16,4 +16,10 @@ %1$d차 발화 대본 일치율 + + + 연습하기 + 이전 페이지 + 다음 페이지 + 연습한 횟수 From 975cdf21887af1f0193b61fa8bb331f699eaf7d3 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Mon, 18 May 2026 20:00:13 +0900 Subject: [PATCH 18/19] =?UTF-8?q?refactor:=20StickGraph=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `StickGraph` 입력 데이터 모델 변경** * `StickGraph`의 입력 파라미터 타입을 `ImmutableList`에서 `ImmutableMap`로 변경했습니다. * 더 이상 사용되지 않는 `StickData` 데이터 클래스를 제거했습니다. * 입력 데이터가 Map으로 변경됨에 따라 기존의 데이터 집계 로직(`aggregateByItemType`)을 제거하고 `toStickGraphBars` 변환 로직을 단순화했습니다. * **style: 코드 스타일 및 프리뷰 구성 개선** * `StickGraphItemType.itemLabel()` 함수 내 `stringResource` 호출 방식을 보다 간결하게 수정했습니다. * `@Preview` 어노테이션을 커스텀 프리뷰인 `@BasicPreview`로 교체했습니다. * `StickGraphPreview` 내 샘플 데이터를 `persistentMapOf`를 사용하도록 업데이트했습니다. --- .../core/ui/component/graph/StickGraph.kt | 72 ++++++++----------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt index 1ded9567..d690c54d 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt @@ -16,13 +16,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.R -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf private const val STICK_GRAPH_MAX_HEIGHT = 160 private val STICK_GRAPH_ITEM_WIDTH = 52.dp @@ -32,12 +32,6 @@ enum class StickGraphItemType { GRAMMAR, } -@Immutable -data class StickData( - val count: Int, - val itemType: StickGraphItemType, -) - @Immutable private data class StickGraphBar( val count: Int, @@ -48,7 +42,7 @@ private data class StickGraphBar( @Composable fun StickGraph( - data: ImmutableList, + data: ImmutableMap, modifier: Modifier = Modifier, ) { require(data.size == StickGraphItemType.entries.size) { @@ -94,31 +88,21 @@ private fun StickGraphItem( } @Composable -private fun ImmutableList.toStickGraphBars(): ImmutableList { - val aggregatedData = aggregateByItemType() - val maxCount = aggregatedData.maxOf(StickData::count) - - return aggregatedData - .map { data -> - StickGraphBar( - count = data.count, - height = data.count.toStickHeight(maxCount = maxCount), - color = data.itemType.itemColor(), - label = data.itemType.itemLabel(), - ) - }.toImmutableList() +private fun ImmutableMap.toStickGraphBars(): List { + val maxCount = values.maxOrNull() ?: 0 + + return StickGraphItemType.entries.map { itemType -> + val count = getValue(itemType) + + StickGraphBar( + count = count, + height = count.toStickHeight(maxCount = maxCount), + color = itemType.itemColor(), + label = itemType.itemLabel(), + ) + } } -private fun ImmutableList.aggregateByItemType(): ImmutableList = - this - .groupBy { stickItem -> stickItem.itemType } - .map { (itemType, items) -> - StickData( - count = items.sumOf { item -> item.count }, - itemType = itemType, - ) - }.toImmutableList() - private fun Int.toStickHeight(maxCount: Int): Dp { if (maxCount <= 0) return 0.dp @@ -135,12 +119,14 @@ private fun StickGraphItemType.itemColor(): Color = @Composable private fun StickGraphItemType.itemLabel(): String = - when (this) { - StickGraphItemType.SPELLING -> R.string.core_ui_impl_stick_graph_spelling_label - StickGraphItemType.GRAMMAR -> R.string.core_ui_impl_stick_graph_grammar_label - }.let { resId -> stringResource(resId) } - -@Preview(showBackground = true) + stringResource( + when (this) { + StickGraphItemType.SPELLING -> R.string.core_ui_impl_stick_graph_spelling_label + StickGraphItemType.GRAMMAR -> R.string.core_ui_impl_stick_graph_grammar_label + }, + ) + +@BasicPreview @Composable private fun StickGraphPreview() { PrezelTheme { @@ -148,10 +134,10 @@ private fun StickGraphPreview() { modifier = Modifier.padding(12.dp), ) { StickGraph( - data = listOf( - StickData(count = 2, itemType = StickGraphItemType.SPELLING), - StickData(count = 1, itemType = StickGraphItemType.GRAMMAR), - ).toImmutableList(), + data = persistentMapOf( + StickGraphItemType.SPELLING to 2, + StickGraphItemType.GRAMMAR to 1, + ), ) } } From 434a407ad629107af821d40d35c42246e5976565 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Tue, 19 May 2026 02:29:53 +0900 Subject: [PATCH 19/19] =?UTF-8?q?refactor:=20StickGraph=20UI=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `ProvideTextStyle` 제거 및 개별 `Text` 스타일 지정** * 공통 텍스트 스타일을 일괄 적용하던 `ProvideTextStyle`을 제거하고, 개별 `Text` 컴포넌트의 `style` 및 `color` 파라미터를 사용하도록 변경했습니다. * 상단 수치(`bar.count`) 텍스트에 `body3Medium` 스타일을 명시적으로 적용했습니다. * 하단 라벨(`bar.label`) 텍스트의 스타일을 `body3Medium`에서 `body3Regular`로 변경하여 시각적 위계를 조정했습니다. * **style: 그래프 막대(`Box`) 코드 포맷팅 개선** * `Box` 컴포넌트의 `modifier` 속성 가독성을 위해 줄바꿈 및 인덴트를 정리했습니다. --- .../core/ui/component/graph/StickGraph.kt | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt index d690c54d..8d4658bf 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -70,20 +69,26 @@ private fun StickGraphItem( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), ) { - ProvideTextStyle(PrezelTheme.typography.body3Medium.copy(color = PrezelTheme.colors.textRegular)) { - Text(text = bar.count.toString()) - - Box( - modifier = modifier - .size( - width = STICK_GRAPH_ITEM_WIDTH, - height = bar.height, - ).clip(PrezelTheme.shapes.V4) - .background(color = bar.color), - ) + Text( + text = bar.count.toString(), + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textRegular, + ) - Text(text = bar.label) - } + Box( + modifier = modifier + .size( + width = STICK_GRAPH_ITEM_WIDTH, + height = bar.height, + ).clip(PrezelTheme.shapes.V4) + .background(color = bar.color), + ) + + Text( + text = bar.label, + style = PrezelTheme.typography.body3Regular, + color = PrezelTheme.colors.textRegular, + ) } }