From 6748458fa463c9e8b5b2433b64e0b6720ba2c44b Mon Sep 17 00:00:00 2001 From: soyeonLee126 Date: Sun, 8 Mar 2026 15:30:07 +0900 Subject: [PATCH 1/4] =?UTF-8?q?-=20barchart=20component=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/InsightCompareBarChartCard.kt | 449 ++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt new file mode 100644 index 00000000..1b8d16f5 --- /dev/null +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt @@ -0,0 +1,449 @@ +package com.nexters.fooddiary.core.ui.component + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nexters.fooddiary.core.ui.theme.AppTypography +import com.nexters.fooddiary.core.ui.theme.FoodDiaryTheme +import com.nexters.fooddiary.core.ui.theme.Gray050 +import com.nexters.fooddiary.core.ui.theme.Gray200 +import com.nexters.fooddiary.core.ui.theme.PrimBase +import com.nexters.fooddiary.core.ui.theme.Sd800 +import com.nexters.fooddiary.core.ui.theme.White +import kotlinx.coroutines.delay + +private val BarLabelSpacing = 10.dp +private val BarLabelLineHeight = 14.sp +private val BarLabelTextStyle = AppTypography.p12.copy( + fontSize = 10.sp, + lineHeight = BarLabelLineHeight, + letterSpacing = (-0.15).sp, + platformStyle = PlatformTextStyle(includeFontPadding = false), +) + +@Immutable +data class InsightCompareBarItem( + val label: String, + val value: Int, + val valueText: String = value.toString(), + val topColor: Color, + val bottomColor: Color, + val animationDurationMillis: Int = 900, + val animationDelayMillis: Int = 0, +) + +fun List.withStaggeredAnimation( + delayStepMillis: Int = 100, + startDelayMillis: Int = 0, +): List = mapIndexed { index, item -> + item.copy(animationDelayMillis = startDelayMillis + (index * delayStepMillis)) +} + +@Composable +fun InsightCompareBarChartCard( + title: String, + descriptionPrefix: String, + highlightText: String, + descriptionSuffix: String, + bars: List, + modifier: Modifier = Modifier, +) { + val titleStyle = AppTypography.p15.copy( + fontWeight = FontWeight.SemiBold, + lineHeight = 21.sp, + letterSpacing = (-0.225).sp, + ) + val highlightStyle = titleStyle.copy(color = PrimBase) + + InsightBarChartCard( + bars = bars, + modifier = modifier, + barSpacing = 60.dp, + headerContent = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + style = titleStyle, + color = Gray050, + ) + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = Gray200, fontSize = 10.sp)) { + append(descriptionPrefix) + } + withStyle( + SpanStyle( + color = highlightStyle.color, + fontSize = highlightStyle.fontSize, + fontWeight = highlightStyle.fontWeight, + letterSpacing = highlightStyle.letterSpacing, + ) + ) { + append(highlightText) + } + withStyle(SpanStyle(color = Gray200, fontSize = 10.sp)) { + append(descriptionSuffix) + } + }, + style = AppTypography.p12, + ) + } + }, + ) +} + +@Composable +fun InsightFrequentFoodBarChartCard( + title: String, + description: String, + highlightFoodName: String, + bars: List, + modifier: Modifier = Modifier, +) { + val titleStyle = AppTypography.p15.copy( + fontWeight = FontWeight.SemiBold, + lineHeight = 21.sp, + letterSpacing = (-0.225).sp, + ) + val subtitleStyle = titleStyle + + InsightBarChartCard( + bars = bars, + modifier = modifier, + barSpacing = 10.dp, + headerContent = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = titleStyle, + color = Gray050, + ) + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = Gray050)) { + append("음식은 ") + } + withStyle(SpanStyle(color = PrimBase)) { + append(highlightFoodName) + } + }, + style = subtitleStyle, + ) + } + Text( + text = description, + style = AppTypography.p12.copy( + fontSize = 10.sp, + lineHeight = 14.sp, + letterSpacing = (-0.15).sp, + ), + color = Gray200, + ) + } + }, + ) +} + +@Composable +private fun InsightBarChartCard( + bars: List, + modifier: Modifier = Modifier, + headerChartSpacing: Dp = 32.dp, + barSpacing: Dp = 60.dp, + headerContent: @Composable () -> Unit, +) { + val maxValue = bars.maxOfOrNull { it.value }?.coerceAtLeast(1) ?: 1 + + Column( + modifier = modifier + .fillMaxWidth() + .insightCardContainer() + .padding(horizontal = 16.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(headerChartSpacing), + ) { + headerContent() + + BarChart( + bars = bars, + maxValue = maxValue, + barSpacing = barSpacing, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun BarChart( + bars: List, + maxValue: Int, + barSpacing: Dp, + modifier: Modifier = Modifier, +) { + val chartAreaHeight = 146.dp + val labelAreaHeight = BarLabelSpacing + BarLabelLineHeight.value.dp + + Box( + modifier = modifier.height(182.dp), + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(182.dp) + .align(Alignment.BottomCenter) + .horizontalGridLinesFromBottom( + lineCount = 5, + lineColor = Sd800, + chartAreaHeight = chartAreaHeight, + bottomReservedHeight = labelAreaHeight, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = barSpacing, + alignment = Alignment.CenterHorizontally, + ), + ) { + bars.forEach { item -> + BarItem( + item = item, + ratio = item.value / maxValue.toFloat(), + barMaxHeight = chartAreaHeight, + ) + } + } + } +} + +@Composable +private fun BarItem( + item: InsightCompareBarItem, + ratio: Float, + barMaxHeight: androidx.compose.ui.unit.Dp, +) { + val animatableRatio = remember(item.label) { Animatable(0f) } + val clampedRatio = ratio.coerceIn(0f, 1f) + LaunchedEffect(clampedRatio, item.animationDurationMillis, item.animationDelayMillis) { + animatableRatio.snapTo(0f) + if (item.animationDelayMillis > 0) { + delay(item.animationDelayMillis.toLong()) + } + animatableRatio.animateTo( + targetValue = clampedRatio, + animationSpec = tween( + durationMillis = item.animationDurationMillis, + easing = FastOutSlowInEasing, + ), + ) + } + val animatedRatio = animatableRatio.value + val minBarHeight = 12.dp + val barHeight = (barMaxHeight * animatedRatio).coerceAtLeast(minBarHeight) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(BarLabelSpacing), + ) { + Box( + modifier = Modifier.height(barMaxHeight), + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .width(42.dp) + .height(barHeight) + .background( + brush = Brush.verticalGradient( + colors = listOf(item.topColor, item.bottomColor), + ), + shape = RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp), + ), + contentAlignment = Alignment.TopCenter, + ) { + Text( + text = item.valueText, + modifier = Modifier.padding(top = 8.dp), + style = BarLabelTextStyle, + color = White, + ) + } + } + + Text( + text = item.label, + modifier = Modifier.height(BarLabelLineHeight.value.dp), + style = BarLabelTextStyle, + color = Gray200, + ) + } +} + +private fun Modifier.insightCardContainer(): Modifier = background( + color = White.copy(alpha = 0.02f), + shape = RoundedCornerShape(16.dp), +) + +private fun Modifier.horizontalGridLinesFromBottom( + lineCount: Int, + lineColor: Color, + chartAreaHeight: androidx.compose.ui.unit.Dp, + bottomReservedHeight: androidx.compose.ui.unit.Dp, +): Modifier = drawBehind { + val chartAreaPx = chartAreaHeight.toPx() + val bottomReservedPx = bottomReservedHeight.toPx() + val zeroAxisY = size.height - bottomReservedPx + val lineGap = chartAreaPx / lineCount + + repeat(lineCount + 1) { index -> + val y = zeroAxisY - (lineGap * index) + drawLine( + color = lineColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1.dp.toPx(), + ) + } +} + +@Preview +@Composable +private fun InsightCompareBarChartCardPreview() { + FoodDiaryTheme { + InsightCompareBarChartCard( + title = "먹기 전에\n카메라부터 찾았네요.", + descriptionPrefix = "지난 달 대비 기록된 사진이 ", + highlightText = "70%", + descriptionSuffix = " 증가했어요.", + bars = listOf( + InsightCompareBarItem( + label = "1월", + value = 20, + topColor = Color(0xFF415199), + bottomColor = Color(0xFF8AA6E6), + animationDelayMillis = 0, + ), + InsightCompareBarItem( + label = "2월", + value = 140, + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + animationDelayMillis = 180, + ), + ), + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview +@Composable +private fun InsightCompareBarChartCardSmallGapPreview() { + FoodDiaryTheme { + InsightCompareBarChartCard( + title = "사진 기록 습관이\n조금 늘었어요.", + descriptionPrefix = "지난 주 대비 기록된 사진이 ", + highlightText = "12%", + descriptionSuffix = " 증가했어요.", + bars = listOf( + InsightCompareBarItem( + label = "이번 주", + value = 76, + topColor = Color(0xFF415199), + bottomColor = Color(0xFF8AA6E6), + animationDelayMillis = 0, + ), + InsightCompareBarItem( + label = "지난 주", + value = 68, + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + animationDelayMillis = 180, + ), + ), + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview +@Composable +private fun InsightFrequentFoodBarChartCardPreview() { + FoodDiaryTheme { + InsightFrequentFoodBarChartCard( + title = "가장 자주 먹은", + description = "고민은 길었고, 메뉴는 늘 비슷했어요.", + highlightFoodName = "마라샹궈", + bars = listOf( + InsightCompareBarItem( + label = "1주차", + value = 3, + valueText = "3회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + InsightCompareBarItem( + label = "2주차", + value = 4, + valueText = "4회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + InsightCompareBarItem( + label = "3주차", + value = 2, + valueText = "2회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + InsightCompareBarItem( + label = "4주차", + value = 5, + valueText = "5회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + InsightCompareBarItem( + label = "5주차", + value = 6, + valueText = "6회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + ).withStaggeredAnimation(delayStepMillis = 80), + modifier = Modifier.padding(16.dp), + ) + } +} From 40f8c5a1fd5b9886bd277880f87ba8e965bf4119 Mon Sep 17 00:00:00 2001 From: soyeonLee126 Date: Sun, 8 Mar 2026 16:17:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?-=20modifier=20extension=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20-=20component=20=EC=9D=B4=EB=A6=84=20=EB=B2=94?= =?UTF-8?q?=EC=9A=A9=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-?= =?UTF-8?q?=20=EB=B9=84=EC=9C=A8=20=EA=B8=B0=EB=B0=98=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/InsightCompareBarChartCard.kt | 222 +++++++----------- .../core/ui/theme/ModifierExtensions.kt | 23 ++ 2 files changed, 112 insertions(+), 133 deletions(-) diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt index 1b8d16f5..664f619b 100644 --- a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt @@ -1,5 +1,6 @@ package com.nexters.fooddiary.core.ui.component +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -13,21 +14,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text -import androidx.compose.animation.core.Animatable import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -40,55 +38,71 @@ import com.nexters.fooddiary.core.ui.theme.Gray200 import com.nexters.fooddiary.core.ui.theme.PrimBase import com.nexters.fooddiary.core.ui.theme.Sd800 import com.nexters.fooddiary.core.ui.theme.White +import com.nexters.fooddiary.core.ui.theme.horizontalGridLinesFromBottom import kotlinx.coroutines.delay +private val DefaultChartHeight = 182.dp +private val DefaultCompareBarSpacing = 60.dp +private val DefaultFrequentBarSpacing = 10.dp +private const val ChartLineCount = 5 +private val BarWidth = 42.dp +private val BarTopRadius = 2.dp +private val ValueTopPadding = 8.dp private val BarLabelSpacing = 10.dp private val BarLabelLineHeight = 14.sp +private val MinChartAreaHeight = 120.dp private val BarLabelTextStyle = AppTypography.p12.copy( fontSize = 10.sp, - lineHeight = BarLabelLineHeight, + lineHeight = 14.sp, letterSpacing = (-0.15).sp, platformStyle = PlatformTextStyle(includeFontPadding = false), ) +private val CardTitleTextStyle = AppTypography.p15.copy( + fontWeight = FontWeight.SemiBold, + lineHeight = 21.sp, + letterSpacing = (-0.225).sp, +) @Immutable -data class InsightCompareBarItem( +data class BarItem( val label: String, - val value: Int, - val valueText: String = value.toString(), + val ratio: Float, + val valueText: String, val topColor: Color, val bottomColor: Color, val animationDurationMillis: Int = 900, val animationDelayMillis: Int = 0, ) -fun List.withStaggeredAnimation( +fun List.withStaggeredAnimation( delayStepMillis: Int = 100, startDelayMillis: Int = 0, -): List = mapIndexed { index, item -> +): List { + if (isEmpty() || (delayStepMillis == 0 && startDelayMillis == 0)) return this + return mapIndexed { index, item -> item.copy(animationDelayMillis = startDelayMillis + (index * delayStepMillis)) } +} @Composable -fun InsightCompareBarChartCard( +fun BarChartCard( title: String, descriptionPrefix: String, highlightText: String, descriptionSuffix: String, - bars: List, + bars: List, modifier: Modifier = Modifier, + barSpacing: Dp = DefaultCompareBarSpacing, + chartHeight: Dp = DefaultChartHeight, ) { - val titleStyle = AppTypography.p15.copy( - fontWeight = FontWeight.SemiBold, - lineHeight = 21.sp, - letterSpacing = (-0.225).sp, - ) + val titleStyle = CardTitleTextStyle val highlightStyle = titleStyle.copy(color = PrimBase) - InsightBarChartCard( + BaseBarChartCard( bars = bars, modifier = modifier, - barSpacing = 60.dp, + barSpacing = barSpacing, + chartHeight = chartHeight, headerContent = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -115,7 +129,7 @@ fun InsightCompareBarChartCard( append(descriptionSuffix) } }, - style = AppTypography.p12, + style = BarLabelTextStyle, ) } }, @@ -126,21 +140,20 @@ fun InsightCompareBarChartCard( fun InsightFrequentFoodBarChartCard( title: String, description: String, + highlightPrefixText: String, highlightFoodName: String, - bars: List, + bars: List, modifier: Modifier = Modifier, + barSpacing: Dp = DefaultFrequentBarSpacing, + chartHeight: Dp = DefaultChartHeight, ) { - val titleStyle = AppTypography.p15.copy( - fontWeight = FontWeight.SemiBold, - lineHeight = 21.sp, - letterSpacing = (-0.225).sp, - ) - val subtitleStyle = titleStyle + val titleStyle = CardTitleTextStyle - InsightBarChartCard( + BaseBarChartCard( bars = bars, modifier = modifier, - barSpacing = 10.dp, + barSpacing = barSpacing, + chartHeight = chartHeight, headerContent = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -152,22 +165,18 @@ fun InsightFrequentFoodBarChartCard( Text( text = buildAnnotatedString { withStyle(SpanStyle(color = Gray050)) { - append("음식은 ") + append(highlightPrefixText) } withStyle(SpanStyle(color = PrimBase)) { append(highlightFoodName) } }, - style = subtitleStyle, + style = titleStyle, ) } Text( text = description, - style = AppTypography.p12.copy( - fontSize = 10.sp, - lineHeight = 14.sp, - letterSpacing = (-0.15).sp, - ), + style = BarLabelTextStyle, color = Gray200, ) } @@ -176,15 +185,14 @@ fun InsightFrequentFoodBarChartCard( } @Composable -private fun InsightBarChartCard( - bars: List, +private fun BaseBarChartCard( + bars: List, modifier: Modifier = Modifier, headerChartSpacing: Dp = 32.dp, - barSpacing: Dp = 60.dp, + barSpacing: Dp, + chartHeight: Dp, headerContent: @Composable () -> Unit, ) { - val maxValue = bars.maxOfOrNull { it.value }?.coerceAtLeast(1) ?: 1 - Column( modifier = modifier .fillMaxWidth() @@ -196,8 +204,8 @@ private fun InsightBarChartCard( BarChart( bars = bars, - maxValue = maxValue, barSpacing = barSpacing, + chartHeight = chartHeight, modifier = Modifier.fillMaxWidth(), ) } @@ -205,25 +213,25 @@ private fun InsightBarChartCard( @Composable private fun BarChart( - bars: List, - maxValue: Int, + bars: List, barSpacing: Dp, + chartHeight: Dp, modifier: Modifier = Modifier, ) { - val chartAreaHeight = 146.dp val labelAreaHeight = BarLabelSpacing + BarLabelLineHeight.value.dp + val chartAreaHeight = (chartHeight - labelAreaHeight).coerceAtLeast(MinChartAreaHeight) Box( - modifier = modifier.height(182.dp), + modifier = modifier.height(chartHeight), contentAlignment = Alignment.BottomCenter, ) { Box( modifier = Modifier .fillMaxWidth() - .height(182.dp) + .height(chartHeight) .align(Alignment.BottomCenter) .horizontalGridLinesFromBottom( - lineCount = 5, + lineCount = ChartLineCount, lineColor = Sd800, chartAreaHeight = chartAreaHeight, bottomReservedHeight = labelAreaHeight, @@ -238,9 +246,8 @@ private fun BarChart( ), ) { bars.forEach { item -> - BarItem( + BarGraphItem( item = item, - ratio = item.value / maxValue.toFloat(), barMaxHeight = chartAreaHeight, ) } @@ -249,13 +256,13 @@ private fun BarChart( } @Composable -private fun BarItem( - item: InsightCompareBarItem, - ratio: Float, - barMaxHeight: androidx.compose.ui.unit.Dp, +private fun BarGraphItem( + item: BarItem, + barMaxHeight: Dp, ) { - val animatableRatio = remember(item.label) { Animatable(0f) } - val clampedRatio = ratio.coerceIn(0f, 1f) + val animatableRatio = remember(item) { Animatable(0f) } + val clampedRatio = item.ratio.coerceIn(0f, 1f) + LaunchedEffect(clampedRatio, item.animationDurationMillis, item.animationDelayMillis) { animatableRatio.snapTo(0f) if (item.animationDelayMillis > 0) { @@ -269,9 +276,9 @@ private fun BarItem( ), ) } - val animatedRatio = animatableRatio.value + val minBarHeight = 12.dp - val barHeight = (barMaxHeight * animatedRatio).coerceAtLeast(minBarHeight) + val barHeight = (barMaxHeight * animatableRatio.value).coerceAtLeast(minBarHeight) Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -283,19 +290,19 @@ private fun BarItem( ) { Box( modifier = Modifier - .width(42.dp) + .width(BarWidth) .height(barHeight) .background( brush = Brush.verticalGradient( colors = listOf(item.topColor, item.bottomColor), ), - shape = RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp), + shape = RoundedCornerShape(topStart = BarTopRadius, topEnd = BarTopRadius), ), contentAlignment = Alignment.TopCenter, ) { Text( text = item.valueText, - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = ValueTopPadding), style = BarLabelTextStyle, color = White, ) @@ -316,83 +323,31 @@ private fun Modifier.insightCardContainer(): Modifier = background( shape = RoundedCornerShape(16.dp), ) -private fun Modifier.horizontalGridLinesFromBottom( - lineCount: Int, - lineColor: Color, - chartAreaHeight: androidx.compose.ui.unit.Dp, - bottomReservedHeight: androidx.compose.ui.unit.Dp, -): Modifier = drawBehind { - val chartAreaPx = chartAreaHeight.toPx() - val bottomReservedPx = bottomReservedHeight.toPx() - val zeroAxisY = size.height - bottomReservedPx - val lineGap = chartAreaPx / lineCount - - repeat(lineCount + 1) { index -> - val y = zeroAxisY - (lineGap * index) - drawLine( - color = lineColor, - start = Offset(0f, y), - end = Offset(size.width, y), - strokeWidth = 1.dp.toPx(), - ) - } -} - @Preview @Composable -private fun InsightCompareBarChartCardPreview() { +private fun BarChartCardPreview() { FoodDiaryTheme { - InsightCompareBarChartCard( + BarChartCard( title = "먹기 전에\n카메라부터 찾았네요.", descriptionPrefix = "지난 달 대비 기록된 사진이 ", highlightText = "70%", descriptionSuffix = " 증가했어요.", bars = listOf( - InsightCompareBarItem( + BarItem( label = "1월", - value = 20, + ratio = 20f / 140f, + valueText = "20", topColor = Color(0xFF415199), bottomColor = Color(0xFF8AA6E6), - animationDelayMillis = 0, ), - InsightCompareBarItem( + BarItem( label = "2월", - value = 140, + ratio = 1f, + valueText = "140", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), - animationDelayMillis = 180, ), - ), - modifier = Modifier.padding(16.dp), - ) - } -} - -@Preview -@Composable -private fun InsightCompareBarChartCardSmallGapPreview() { - FoodDiaryTheme { - InsightCompareBarChartCard( - title = "사진 기록 습관이\n조금 늘었어요.", - descriptionPrefix = "지난 주 대비 기록된 사진이 ", - highlightText = "12%", - descriptionSuffix = " 증가했어요.", - bars = listOf( - InsightCompareBarItem( - label = "이번 주", - value = 76, - topColor = Color(0xFF415199), - bottomColor = Color(0xFF8AA6E6), - animationDelayMillis = 0, - ), - InsightCompareBarItem( - label = "지난 주", - value = 68, - topColor = Color(0xFFFE670E), - bottomColor = Color(0xFFFFB183), - animationDelayMillis = 180, - ), - ), + ).withStaggeredAnimation(delayStepMillis = 90), modifier = Modifier.padding(16.dp), ) } @@ -405,44 +360,45 @@ private fun InsightFrequentFoodBarChartCardPreview() { InsightFrequentFoodBarChartCard( title = "가장 자주 먹은", description = "고민은 길었고, 메뉴는 늘 비슷했어요.", + highlightPrefixText = "음식은 ", highlightFoodName = "마라샹궈", bars = listOf( - InsightCompareBarItem( + BarItem( label = "1주차", - value = 3, + ratio = 0.5f, valueText = "3회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), - InsightCompareBarItem( + BarItem( label = "2주차", - value = 4, + ratio = 0.6f, valueText = "4회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), - InsightCompareBarItem( + BarItem( label = "3주차", - value = 2, + ratio = 0.1f, valueText = "2회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), - InsightCompareBarItem( + BarItem( label = "4주차", - value = 5, + ratio = 0.9f, valueText = "5회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), - InsightCompareBarItem( + BarItem( label = "5주차", - value = 6, + ratio = 1f, valueText = "6회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), - ).withStaggeredAnimation(delayStepMillis = 80), + ).withStaggeredAnimation(delayStepMillis = 70), modifier = Modifier.padding(16.dp), ) } diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/ModifierExtensions.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/ModifierExtensions.kt index 700e4d22..3db19743 100644 --- a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/ModifierExtensions.kt +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/theme/ModifierExtensions.kt @@ -39,3 +39,26 @@ fun Modifier.neonShadow( ) } } + + +fun Modifier.horizontalGridLinesFromBottom( + lineCount: Int, + lineColor: Color, + chartAreaHeight: Dp, + bottomReservedHeight: Dp, +): Modifier = drawBehind { + val chartAreaPx = chartAreaHeight.toPx() + val bottomReservedPx = bottomReservedHeight.toPx() + val zeroAxisY = size.height - bottomReservedPx + val lineGap = chartAreaPx / lineCount + + repeat(lineCount + 1) { index -> + val y = zeroAxisY - (lineGap * index) + drawLine( + color = lineColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1.dp.toPx(), + ) + } +} From 3ab6d030334284d41bf9573ddec98025ed3b6f08 Mon Sep 17 00:00:00 2001 From: soyeonLee126 Date: Sun, 8 Mar 2026 16:33:11 +0900 Subject: [PATCH 3/4] =?UTF-8?q?-=20BarChartCardDefaults=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/ui/component/BarChartCard.kt | 201 +++++++++ .../core/ui/component/BarChartCardDefaults.kt | 16 + .../core/ui/component/BarChartCardInternal.kt | 194 +++++++++ .../core/ui/component/BarChartCardModels.kt | 30 ++ .../component/InsightCompareBarChartCard.kt | 405 ------------------ 5 files changed, 441 insertions(+), 405 deletions(-) create mode 100644 core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt create mode 100644 core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardDefaults.kt create mode 100644 core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt create mode 100644 core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt delete mode 100644 core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt new file mode 100644 index 00000000..417c3196 --- /dev/null +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt @@ -0,0 +1,201 @@ +package com.nexters.fooddiary.core.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nexters.fooddiary.core.ui.theme.FoodDiaryTheme +import com.nexters.fooddiary.core.ui.theme.Gray050 +import com.nexters.fooddiary.core.ui.theme.Gray200 +import com.nexters.fooddiary.core.ui.theme.PrimBase + +@Composable +fun BarChartCard( + title: String, + descriptionPrefix: String, + highlightText: String, + descriptionSuffix: String, + bars: List, + modifier: Modifier = Modifier, + barSpacing: Dp = BarChartCardDefaults.CompareBarSpacing, + chartHeight: Dp = BarChartCardDefaults.ChartHeight, +) { + val titleStyle = BarChartTitleTextStyle + val highlightStyle = titleStyle.copy(color = PrimBase) + + BaseBarChartCard( + bars = bars, + modifier = modifier, + barSpacing = barSpacing, + chartHeight = chartHeight, + headerContent = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + style = titleStyle, + color = Gray050, + ) + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = Gray200, fontSize = 10.sp)) { + append(descriptionPrefix) + } + withStyle( + SpanStyle( + color = highlightStyle.color, + fontSize = highlightStyle.fontSize, + fontWeight = highlightStyle.fontWeight, + letterSpacing = highlightStyle.letterSpacing, + ) + ) { + append(highlightText) + } + withStyle(SpanStyle(color = Gray200, fontSize = 10.sp)) { + append(descriptionSuffix) + } + }, + style = BarChartLabelTextStyle, + ) + } + }, + ) +} + +@Composable +fun HighlightedSubjectBarChartCard( + title: String, + description: String, + highlightPrefixText: String, + highlightedText: String, + bars: List, + modifier: Modifier = Modifier, + barSpacing: Dp = BarChartCardDefaults.FrequentBarSpacing, + chartHeight: Dp = BarChartCardDefaults.ChartHeight, +) { + val titleStyle = BarChartTitleTextStyle + + BaseBarChartCard( + bars = bars, + modifier = modifier, + barSpacing = barSpacing, + chartHeight = chartHeight, + headerContent = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = titleStyle, + color = Gray050, + ) + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = Gray050)) { + append(highlightPrefixText) + } + withStyle(SpanStyle(color = PrimBase)) { + append(highlightedText) + } + }, + style = titleStyle, + ) + } + Text( + text = description, + style = BarChartLabelTextStyle, + color = Gray200, + ) + } + }, + ) +} + +@Preview +@Composable +private fun BarChartCardPreview() { + FoodDiaryTheme { + BarChartCard( + title = "먹기 전에\n카메라부터 찾았네요.", + descriptionPrefix = "지난 달 대비 기록된 사진이 ", + highlightText = "70%", + descriptionSuffix = " 증가했어요.", + bars = listOf( + BarChartItem( + label = "1월", + ratio = 0.2f, + valueText = "20", + topColor = Color(0xFF415199), + bottomColor = Color(0xFF8AA6E6), + ), + BarChartItem( + label = "2월", + ratio = 1f, + valueText = "140", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + ).withStaggeredAnimation(delayStepMillis = 90), + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview +@Composable +private fun HighlightedSubjectBarChartCardPreview() { + FoodDiaryTheme { + HighlightedSubjectBarChartCard( + title = "가장 자주 먹은", + description = "고민은 길었고, 메뉴는 늘 비슷했어요.", + highlightPrefixText = "음식은 ", + highlightedText = "마라샹궈", + bars = listOf( + BarChartItem( + label = "1주차", + ratio = 0.5f, + valueText = "3회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "2주차", + ratio = 0.6f, + valueText = "4회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "3주차", + ratio = 0.1f, + valueText = "2회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "4주차", + ratio = 0.9f, + valueText = "5회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "5주차", + ratio = 1f, + valueText = "6회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + ).withStaggeredAnimation(delayStepMillis = 70), + modifier = Modifier.padding(16.dp), + ) + } +} diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardDefaults.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardDefaults.kt new file mode 100644 index 00000000..bae76e74 --- /dev/null +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardDefaults.kt @@ -0,0 +1,16 @@ +package com.nexters.fooddiary.core.ui.component + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object BarChartCardDefaults { + val ChartHeight = 182.dp + val CompareBarSpacing = 60.dp + val FrequentBarSpacing = 10.dp + val BarWidth = 42.dp + val BarTopRadius = 2.dp + val ValueTopPadding = 8.dp + val BarLabelSpacing = 10.dp + val BarLabelLineHeight = 14.sp + val MinChartAreaHeight = 120.dp +} diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt new file mode 100644 index 00000000..c1efd9e0 --- /dev/null +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt @@ -0,0 +1,194 @@ +package com.nexters.fooddiary.core.ui.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nexters.fooddiary.core.ui.theme.AppTypography +import com.nexters.fooddiary.core.ui.theme.Gray200 +import com.nexters.fooddiary.core.ui.theme.Sd800 +import com.nexters.fooddiary.core.ui.theme.White +import com.nexters.fooddiary.core.ui.theme.horizontalGridLinesFromBottom +import kotlinx.coroutines.delay + +private const val ChartLineCount = 5 +private val BarChartLabelTextStyleInternal = AppTypography.p12.copy( + fontSize = 10.sp, + lineHeight = BarChartCardDefaults.BarLabelLineHeight, + letterSpacing = (-0.15).sp, + platformStyle = PlatformTextStyle(includeFontPadding = false), +) + +internal val BarChartLabelTextStyle: TextStyle = BarChartLabelTextStyleInternal +internal val BarChartTitleTextStyle: TextStyle = AppTypography.p15.copy( + fontWeight = FontWeight.SemiBold, + lineHeight = 21.sp, + letterSpacing = (-0.225).sp, +) + +@Composable +internal fun BaseBarChartCard( + bars: List, + modifier: Modifier = Modifier, + headerChartSpacing: Dp = 32.dp, + barSpacing: Dp, + chartHeight: Dp, + headerContent: @Composable () -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .barChartCardContainer() + .padding(horizontal = 16.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(headerChartSpacing), + ) { + headerContent() + + BarChart( + bars = bars, + barSpacing = barSpacing, + chartHeight = chartHeight, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun BarChart( + bars: List, + barSpacing: Dp, + chartHeight: Dp, + modifier: Modifier = Modifier, +) { + val labelAreaHeight = + BarChartCardDefaults.BarLabelSpacing + BarChartCardDefaults.BarLabelLineHeight.value.dp + val chartAreaHeight = + (chartHeight - labelAreaHeight).coerceAtLeast(BarChartCardDefaults.MinChartAreaHeight) + + Box( + modifier = modifier.height(chartHeight), + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(chartHeight) + .align(Alignment.BottomCenter) + .horizontalGridLinesFromBottom( + lineCount = ChartLineCount, + lineColor = Sd800, + chartAreaHeight = chartAreaHeight, + bottomReservedHeight = labelAreaHeight, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = barSpacing, + alignment = Alignment.CenterHorizontally, + ), + ) { + bars.forEach { item -> + BarGraphItem( + item = item, + barMaxHeight = chartAreaHeight, + ) + } + } + } +} + +@Composable +private fun BarGraphItem( + item: BarChartItem, + barMaxHeight: Dp, +) { + val animatableRatio = remember(item) { Animatable(0f) } + val clampedRatio = item.ratio.coerceIn(0f, 1f) + + LaunchedEffect(clampedRatio, item.animationDurationMillis, item.animationDelayMillis) { + animatableRatio.snapTo(0f) + if (item.animationDelayMillis > 0) { + delay(item.animationDelayMillis.toLong()) + } + animatableRatio.animateTo( + targetValue = clampedRatio, + animationSpec = tween( + durationMillis = item.animationDurationMillis, + easing = FastOutSlowInEasing, + ), + ) + } + + val minBarHeight = 12.dp + val barHeight = (barMaxHeight * animatableRatio.value).coerceAtLeast(minBarHeight) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(BarChartCardDefaults.BarLabelSpacing), + ) { + Box( + modifier = Modifier.height(barMaxHeight), + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .width(BarChartCardDefaults.BarWidth) + .height(barHeight) + .background( + brush = Brush.verticalGradient( + colors = listOf(item.topColor, item.bottomColor), + ), + shape = RoundedCornerShape( + topStart = BarChartCardDefaults.BarTopRadius, + topEnd = BarChartCardDefaults.BarTopRadius, + ), + ), + contentAlignment = Alignment.TopCenter, + ) { + Text( + text = item.valueText, + modifier = Modifier.padding(top = BarChartCardDefaults.ValueTopPadding), + style = BarChartLabelTextStyle, + color = White, + ) + } + } + + Text( + text = item.label, + modifier = Modifier.height(BarChartCardDefaults.BarLabelLineHeight.value.dp), + style = BarChartLabelTextStyle, + color = Gray200, + ) + } +} + +private fun Modifier.barChartCardContainer(): Modifier = background( + color = White.copy(alpha = 0.02f), + shape = RoundedCornerShape(16.dp), +) diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt new file mode 100644 index 00000000..3896d00d --- /dev/null +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt @@ -0,0 +1,30 @@ +package com.nexters.fooddiary.core.ui.component + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class BarChartItem( + val label: String, + val ratio: Float, + val valueText: String, + val topColor: Color, + val bottomColor: Color, + val animationDurationMillis: Int = 900, + val animationDelayMillis: Int = 0, +) + +fun List.withStaggeredAnimation( + delayStepMillis: Int = 100, + startDelayMillis: Int = 0, +): List { + if (isEmpty()) return this + + val safeDelayStep = delayStepMillis.coerceAtLeast(0) + val safeStartDelay = startDelayMillis.coerceAtLeast(0) + if (safeDelayStep == 0 && safeStartDelay == 0) return this + + return mapIndexed { index, item -> + item.copy(animationDelayMillis = safeStartDelay + (index * safeDelayStep)) + } +} diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt deleted file mode 100644 index 664f619b..00000000 --- a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/InsightCompareBarChartCard.kt +++ /dev/null @@ -1,405 +0,0 @@ -package com.nexters.fooddiary.core.ui.component - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.PlatformTextStyle -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.nexters.fooddiary.core.ui.theme.AppTypography -import com.nexters.fooddiary.core.ui.theme.FoodDiaryTheme -import com.nexters.fooddiary.core.ui.theme.Gray050 -import com.nexters.fooddiary.core.ui.theme.Gray200 -import com.nexters.fooddiary.core.ui.theme.PrimBase -import com.nexters.fooddiary.core.ui.theme.Sd800 -import com.nexters.fooddiary.core.ui.theme.White -import com.nexters.fooddiary.core.ui.theme.horizontalGridLinesFromBottom -import kotlinx.coroutines.delay - -private val DefaultChartHeight = 182.dp -private val DefaultCompareBarSpacing = 60.dp -private val DefaultFrequentBarSpacing = 10.dp -private const val ChartLineCount = 5 -private val BarWidth = 42.dp -private val BarTopRadius = 2.dp -private val ValueTopPadding = 8.dp -private val BarLabelSpacing = 10.dp -private val BarLabelLineHeight = 14.sp -private val MinChartAreaHeight = 120.dp -private val BarLabelTextStyle = AppTypography.p12.copy( - fontSize = 10.sp, - lineHeight = 14.sp, - letterSpacing = (-0.15).sp, - platformStyle = PlatformTextStyle(includeFontPadding = false), -) -private val CardTitleTextStyle = AppTypography.p15.copy( - fontWeight = FontWeight.SemiBold, - lineHeight = 21.sp, - letterSpacing = (-0.225).sp, -) - -@Immutable -data class BarItem( - val label: String, - val ratio: Float, - val valueText: String, - val topColor: Color, - val bottomColor: Color, - val animationDurationMillis: Int = 900, - val animationDelayMillis: Int = 0, -) - -fun List.withStaggeredAnimation( - delayStepMillis: Int = 100, - startDelayMillis: Int = 0, -): List { - if (isEmpty() || (delayStepMillis == 0 && startDelayMillis == 0)) return this - return mapIndexed { index, item -> - item.copy(animationDelayMillis = startDelayMillis + (index * delayStepMillis)) -} -} - -@Composable -fun BarChartCard( - title: String, - descriptionPrefix: String, - highlightText: String, - descriptionSuffix: String, - bars: List, - modifier: Modifier = Modifier, - barSpacing: Dp = DefaultCompareBarSpacing, - chartHeight: Dp = DefaultChartHeight, -) { - val titleStyle = CardTitleTextStyle - val highlightStyle = titleStyle.copy(color = PrimBase) - - BaseBarChartCard( - bars = bars, - modifier = modifier, - barSpacing = barSpacing, - chartHeight = chartHeight, - headerContent = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = title, - style = titleStyle, - color = Gray050, - ) - Text( - text = buildAnnotatedString { - withStyle(SpanStyle(color = Gray200, fontSize = 10.sp)) { - append(descriptionPrefix) - } - withStyle( - SpanStyle( - color = highlightStyle.color, - fontSize = highlightStyle.fontSize, - fontWeight = highlightStyle.fontWeight, - letterSpacing = highlightStyle.letterSpacing, - ) - ) { - append(highlightText) - } - withStyle(SpanStyle(color = Gray200, fontSize = 10.sp)) { - append(descriptionSuffix) - } - }, - style = BarLabelTextStyle, - ) - } - }, - ) -} - -@Composable -fun InsightFrequentFoodBarChartCard( - title: String, - description: String, - highlightPrefixText: String, - highlightFoodName: String, - bars: List, - modifier: Modifier = Modifier, - barSpacing: Dp = DefaultFrequentBarSpacing, - chartHeight: Dp = DefaultChartHeight, -) { - val titleStyle = CardTitleTextStyle - - BaseBarChartCard( - bars = bars, - modifier = modifier, - barSpacing = barSpacing, - chartHeight = chartHeight, - headerContent = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = title, - style = titleStyle, - color = Gray050, - ) - Text( - text = buildAnnotatedString { - withStyle(SpanStyle(color = Gray050)) { - append(highlightPrefixText) - } - withStyle(SpanStyle(color = PrimBase)) { - append(highlightFoodName) - } - }, - style = titleStyle, - ) - } - Text( - text = description, - style = BarLabelTextStyle, - color = Gray200, - ) - } - }, - ) -} - -@Composable -private fun BaseBarChartCard( - bars: List, - modifier: Modifier = Modifier, - headerChartSpacing: Dp = 32.dp, - barSpacing: Dp, - chartHeight: Dp, - headerContent: @Composable () -> Unit, -) { - Column( - modifier = modifier - .fillMaxWidth() - .insightCardContainer() - .padding(horizontal = 16.dp, vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(headerChartSpacing), - ) { - headerContent() - - BarChart( - bars = bars, - barSpacing = barSpacing, - chartHeight = chartHeight, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@Composable -private fun BarChart( - bars: List, - barSpacing: Dp, - chartHeight: Dp, - modifier: Modifier = Modifier, -) { - val labelAreaHeight = BarLabelSpacing + BarLabelLineHeight.value.dp - val chartAreaHeight = (chartHeight - labelAreaHeight).coerceAtLeast(MinChartAreaHeight) - - Box( - modifier = modifier.height(chartHeight), - contentAlignment = Alignment.BottomCenter, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(chartHeight) - .align(Alignment.BottomCenter) - .horizontalGridLinesFromBottom( - lineCount = ChartLineCount, - lineColor = Sd800, - chartAreaHeight = chartAreaHeight, - bottomReservedHeight = labelAreaHeight, - ), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - space = barSpacing, - alignment = Alignment.CenterHorizontally, - ), - ) { - bars.forEach { item -> - BarGraphItem( - item = item, - barMaxHeight = chartAreaHeight, - ) - } - } - } -} - -@Composable -private fun BarGraphItem( - item: BarItem, - barMaxHeight: Dp, -) { - val animatableRatio = remember(item) { Animatable(0f) } - val clampedRatio = item.ratio.coerceIn(0f, 1f) - - LaunchedEffect(clampedRatio, item.animationDurationMillis, item.animationDelayMillis) { - animatableRatio.snapTo(0f) - if (item.animationDelayMillis > 0) { - delay(item.animationDelayMillis.toLong()) - } - animatableRatio.animateTo( - targetValue = clampedRatio, - animationSpec = tween( - durationMillis = item.animationDurationMillis, - easing = FastOutSlowInEasing, - ), - ) - } - - val minBarHeight = 12.dp - val barHeight = (barMaxHeight * animatableRatio.value).coerceAtLeast(minBarHeight) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(BarLabelSpacing), - ) { - Box( - modifier = Modifier.height(barMaxHeight), - contentAlignment = Alignment.BottomCenter, - ) { - Box( - modifier = Modifier - .width(BarWidth) - .height(barHeight) - .background( - brush = Brush.verticalGradient( - colors = listOf(item.topColor, item.bottomColor), - ), - shape = RoundedCornerShape(topStart = BarTopRadius, topEnd = BarTopRadius), - ), - contentAlignment = Alignment.TopCenter, - ) { - Text( - text = item.valueText, - modifier = Modifier.padding(top = ValueTopPadding), - style = BarLabelTextStyle, - color = White, - ) - } - } - - Text( - text = item.label, - modifier = Modifier.height(BarLabelLineHeight.value.dp), - style = BarLabelTextStyle, - color = Gray200, - ) - } -} - -private fun Modifier.insightCardContainer(): Modifier = background( - color = White.copy(alpha = 0.02f), - shape = RoundedCornerShape(16.dp), -) - -@Preview -@Composable -private fun BarChartCardPreview() { - FoodDiaryTheme { - BarChartCard( - title = "먹기 전에\n카메라부터 찾았네요.", - descriptionPrefix = "지난 달 대비 기록된 사진이 ", - highlightText = "70%", - descriptionSuffix = " 증가했어요.", - bars = listOf( - BarItem( - label = "1월", - ratio = 20f / 140f, - valueText = "20", - topColor = Color(0xFF415199), - bottomColor = Color(0xFF8AA6E6), - ), - BarItem( - label = "2월", - ratio = 1f, - valueText = "140", - topColor = Color(0xFFFE670E), - bottomColor = Color(0xFFFFB183), - ), - ).withStaggeredAnimation(delayStepMillis = 90), - modifier = Modifier.padding(16.dp), - ) - } -} - -@Preview -@Composable -private fun InsightFrequentFoodBarChartCardPreview() { - FoodDiaryTheme { - InsightFrequentFoodBarChartCard( - title = "가장 자주 먹은", - description = "고민은 길었고, 메뉴는 늘 비슷했어요.", - highlightPrefixText = "음식은 ", - highlightFoodName = "마라샹궈", - bars = listOf( - BarItem( - label = "1주차", - ratio = 0.5f, - valueText = "3회", - topColor = Color(0xFFFE670E), - bottomColor = Color(0xFFFFB183), - ), - BarItem( - label = "2주차", - ratio = 0.6f, - valueText = "4회", - topColor = Color(0xFFFE670E), - bottomColor = Color(0xFFFFB183), - ), - BarItem( - label = "3주차", - ratio = 0.1f, - valueText = "2회", - topColor = Color(0xFFFE670E), - bottomColor = Color(0xFFFFB183), - ), - BarItem( - label = "4주차", - ratio = 0.9f, - valueText = "5회", - topColor = Color(0xFFFE670E), - bottomColor = Color(0xFFFFB183), - ), - BarItem( - label = "5주차", - ratio = 1f, - valueText = "6회", - topColor = Color(0xFFFE670E), - bottomColor = Color(0xFFFFB183), - ), - ).withStaggeredAnimation(delayStepMillis = 70), - modifier = Modifier.padding(16.dp), - ) - } -} From 4d9dd2fb2d17e461fdf289e291a07334bfaeb7bc Mon Sep 17 00:00:00 2001 From: soyeonLee126 Date: Sun, 8 Mar 2026 16:41:46 +0900 Subject: [PATCH 4/4] =?UTF-8?q?-=20=EB=B0=B1=EB=B6=84=EC=9C=A8=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fooddiary/core/ui/component/BarChartCard.kt | 14 +++++++------- .../core/ui/component/BarChartCardInternal.kt | 7 ++++--- .../core/ui/component/BarChartCardModels.kt | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt index 417c3196..2fcf2ccc 100644 --- a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCard.kt @@ -131,14 +131,14 @@ private fun BarChartCardPreview() { bars = listOf( BarChartItem( label = "1월", - ratio = 0.2f, + percentage = 20f, valueText = "20", topColor = Color(0xFF415199), bottomColor = Color(0xFF8AA6E6), ), BarChartItem( label = "2월", - ratio = 1f, + percentage = 100f, valueText = "140", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), @@ -161,35 +161,35 @@ private fun HighlightedSubjectBarChartCardPreview() { bars = listOf( BarChartItem( label = "1주차", - ratio = 0.5f, + percentage = 50f, valueText = "3회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), BarChartItem( label = "2주차", - ratio = 0.6f, + percentage = 60f, valueText = "4회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), BarChartItem( label = "3주차", - ratio = 0.1f, + percentage = 10f, valueText = "2회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), BarChartItem( label = "4주차", - ratio = 0.9f, + percentage = 90f, valueText = "5회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), ), BarChartItem( label = "5주차", - ratio = 1f, + percentage = 100f, valueText = "6회", topColor = Color(0xFFFE670E), bottomColor = Color(0xFFFFB183), diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt index c1efd9e0..9f563a25 100644 --- a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt @@ -128,15 +128,16 @@ private fun BarGraphItem( barMaxHeight: Dp, ) { val animatableRatio = remember(item) { Animatable(0f) } - val clampedRatio = item.ratio.coerceIn(0f, 1f) + val clampedPercentage = item.percentage.coerceIn(0f, 100f) + val targetRatio = clampedPercentage / 100f - LaunchedEffect(clampedRatio, item.animationDurationMillis, item.animationDelayMillis) { + LaunchedEffect(targetRatio, item.animationDurationMillis, item.animationDelayMillis) { animatableRatio.snapTo(0f) if (item.animationDelayMillis > 0) { delay(item.animationDelayMillis.toLong()) } animatableRatio.animateTo( - targetValue = clampedRatio, + targetValue = targetRatio, animationSpec = tween( durationMillis = item.animationDurationMillis, easing = FastOutSlowInEasing, diff --git a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt index 3896d00d..a4c92243 100644 --- a/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardModels.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.graphics.Color @Immutable data class BarChartItem( val label: String, - val ratio: Float, + val percentage: Float, val valueText: String, val topColor: Color, val bottomColor: Color,