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..2fcf2ccc --- /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월", + percentage = 20f, + valueText = "20", + topColor = Color(0xFF415199), + bottomColor = Color(0xFF8AA6E6), + ), + BarChartItem( + label = "2월", + percentage = 100f, + 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주차", + percentage = 50f, + valueText = "3회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "2주차", + percentage = 60f, + valueText = "4회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "3주차", + percentage = 10f, + valueText = "2회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "4주차", + percentage = 90f, + valueText = "5회", + topColor = Color(0xFFFE670E), + bottomColor = Color(0xFFFFB183), + ), + BarChartItem( + label = "5주차", + percentage = 100f, + 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..9f563a25 --- /dev/null +++ b/core/ui/src/main/java/com/nexters/fooddiary/core/ui/component/BarChartCardInternal.kt @@ -0,0 +1,195 @@ +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 clampedPercentage = item.percentage.coerceIn(0f, 100f) + val targetRatio = clampedPercentage / 100f + + LaunchedEffect(targetRatio, item.animationDurationMillis, item.animationDelayMillis) { + animatableRatio.snapTo(0f) + if (item.animationDelayMillis > 0) { + delay(item.animationDelayMillis.toLong()) + } + animatableRatio.animateTo( + targetValue = targetRatio, + 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..a4c92243 --- /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 percentage: 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/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(), + ) + } +}