diff --git a/Prezel/core/ui/build.gradle.kts b/Prezel/core/ui/build.gradle.kts index 560955bf..5a2f1151 100644 --- a/Prezel/core/ui/build.gradle.kts +++ b/Prezel/core/ui/build.gradle.kts @@ -10,4 +10,6 @@ dependencies { implementation(projects.coreDesignsystem) 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..cf1c6f3f --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt @@ -0,0 +1,359 @@ +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.res.stringResource +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.R +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 = stringResource(R.string.core_ui_impl_practice_card_action), + 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 = stringResource(R.string.core_ui_impl_practice_card_prev_page), + tint = chevronIconTintColor(enabled = hasPreviousPage), + modifier = Modifier.noRippleClickable(onClickLeft), + ) + } + + Text( + text = stringResource(R.string.core_ui_impl_practice_card_count_label), + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + ) + + if (showChevron) { + Icon( + painter = painterResource(PrezelIcons.ChevronRight), + contentDescription = stringResource(R.string.core_ui_impl_practice_card_next_page), + 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 = null, + 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(), + ) + } + } +} 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..16d2d7e8 --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/CardGraph.kt @@ -0,0 +1,859 @@ +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.ColumnScope +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.Immutable +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.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 +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 +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 const val CARD_GRAPH_ASPECT_RATIO = 1.5f + +@Immutable +private data class CardGraphUiState( + val enableScroll: Boolean, + val contentWidth: Dp, + val xAxisCenters: List, + 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( + val speech: List, + 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, + modifier: Modifier = Modifier, + selectedItemIndex: Int? = null, + showDetail: Boolean = true, + useContainerStyle: Boolean = true, + onSelectItem: (Int) -> Unit = {}, +) { + require(items.isNotEmpty()) { "CardGraph items must not be empty." } + + val xAxisCenters = rememberCardGraphXAxisCenters(itemCount = items.size) + + BoxWithConstraints { + val uiState = CardGraphUiState( + enableScroll = items.shouldEnableHorizontalScroll(), + contentWidth = maxWidth.toChartContentWidth(itemCount = items.size), + xAxisCenters = xAxisCenters, + selectedItemIndex = items.resolveSelectedItemIndex(selectedItemIndex), + ) + + CardGraphContainer( + modifier = modifier, + useContainerStyle = useContainerStyle, + ) { + CardGraphContent( + items = items, + uiState = uiState, + onChangePosition = { index, center -> + xAxisCenters[index] = center + }, + onSelectItem = onSelectItem, + modifier = Modifier.weight(1f, fill = false), + ) + + if (showDetail) { + DetailContainer( + items = items, + selectedItemIndex = uiState.selectedItemIndex, + ) + } + } + } +} + +@Composable +private fun CardGraphContainer( + useContainerStyle: Boolean, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + 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 +private fun CardGraphContent( + items: ImmutableList, + uiState: CardGraphUiState, + onChangePosition: (index: Int, center: Float) -> Unit, + onSelectItem: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .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)) + } +} + +@Composable +private fun LinearChart( + items: ImmutableList, + uiState: CardGraphUiState, + onSelectItem: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = cardGraphColors() + val dimensions = rememberCardGraphDimensions() + + Box( + modifier = modifier.pointerInput(items.size) { + detectTapGestures { tapOffset -> + with(CardGraphMath) { + uiState.xAxisCenters.findClosestIndex(tapOffset.x)?.let(onSelectItem) + } + } + }, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val chartState = with(CardGraphMath) { + items.toChartState( + xAxisCenters = uiState.xAxisCenters, + chartHeight = size.height, + selectedItemIndex = uiState.selectedItemIndex, + ) + } + with(CardGraphDrawers) { + drawChart( + chartState = chartState, + xAxisCenters = uiState.xAxisCenters, + colors = colors, + dimensions = dimensions, + ) + } + } + } +} + +@Composable +private fun XAxisRow( + size: Int, + onSelectItem: (Int) -> Unit, + modifier: Modifier = Modifier, + onChangePosition: (index: Int, center: Float) -> Unit, +) { + val horizontalArrangement = if (size == 1) Arrangement.Center else Arrangement.SpaceBetween + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = horizontalArrangement, + ) { + repeat(size) { index -> + Text( + 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 -> + val center = coordinates.positionInParent().x + (coordinates.size.width / 2f) + onChangePosition(index, center) + }, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun DetailContainer( + items: ImmutableList, + selectedItemIndex: Int?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + LegendItem( + label = stringResource(R.string.core_ui_impl_card_graph_speech_label), + color = PrezelTheme.colors.feedbackGoodRegular, + valueText = with(CardGraphTextFormatter) { + items.toDetailValueText( + selectedItemIndex = selectedItemIndex, + valueSelector = CardGraphItem::speech, + ) + }, + modifier = Modifier.weight(1f), + ) + PrezelVerticalDivider( + type = PrezelDividerType.THICK, + color = PrezelTheme.colors.borderRegular, + modifier = Modifier.fillMaxHeight(), + ) + LegendItem( + label = stringResource(R.string.core_ui_impl_card_graph_script_match_label), + color = PrezelTheme.colors.feedbackWarningRegular, + valueText = with(CardGraphTextFormatter) { + 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, + ) + } +} + +@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 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 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 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, + ) + } + + 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(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, + ) { + 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), + ), + ) + } + } + + /** 차트 영역의 하단 기준선과 x축 경계를 그린다. */ + private fun DrawScope.drawBaseline( + baselineY: Float, + color: Color, + ) { + drawLine( + color = color, + start = Offset(x = 0f, y = baselineY), + end = Offset(x = size.width, y = baselineY), + strokeWidth = CHART_BASELINE_STROKE_WIDTH, + ) + } + + /** 선택된 인덱스가 있을 때 해당 x축 위치의 세로 가이드를 그린다. */ + 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, + ) + } + + /** 발화와 대본 일치율 시리즈 선을 각각 그린다. */ + 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.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, + ) + } + + 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, + ) + } + } + + /** 선택된 포인트 위에 halo와 중심점을 함께 그린다. */ + 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, + ) + } + + /** 데이터가 1개뿐일 때 선택 상태 없이도 포인트가 보이도록 점을 그린다. */ + private fun DrawScope.drawMarkerIfSinglePoint( + points: List, + color: Color, + radius: Float, + ) { + if (points.size != 1) return + + drawCircle( + color = color, + radius = radius, + center = points.first(), + ) + } + + /** 선택된 x축 위치에 세로 가이드와 삼각형 포인터를 그린다. */ + 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 val cardGraphTenItemPreviewItems = persistentListOf( + CardGraphItem( + speech = 0.76f, + scriptMatch = 0.67f, + ), + 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, + ), + CardGraphItem( + speech = 0.77f, + scriptMatch = 0.66f, + ), + CardGraphItem( + speech = 0.88f, + scriptMatch = 0.94f, + ), +) + +@Composable +private fun CardGraphPreviewContainer( + items: ImmutableList, + selectedItemIndex: Int? = null, + showDetail: Boolean = true, + useContainerStyle: Boolean = true, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) { + CardGraph( + items = items, + selectedItemIndex = selectedItemIndex, + showDetail = showDetail, + useContainerStyle = useContainerStyle, + ) + } +} + +@BasicPreview +@Composable +private fun CardGraphSingleItemPreview() { + PrezelTheme { + CardGraphPreviewContainer(items = cardGraphTenItemPreviewItems.take(1).toImmutableList()) + } +} + +@BasicPreview +@Composable +private fun CardGraphSevenItemPreview() { + PrezelTheme { + CardGraphPreviewContainer(items = cardGraphTenItemPreviewItems.take(7).toImmutableList()) + } +} + +@BasicPreview +@Composable +private fun CardGraphTenItemPreview() { + PrezelTheme { + CardGraphPreviewContainer(items = cardGraphTenItemPreviewItems) + } +} + +@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 = "Background", + state = if (useContainerStyle) ChipState.ACTIVE else ChipState.DEFAULT, + modifier = Modifier.noRippleClickable { useContainerStyle = !useContainerStyle }, + ) + } + + CardGraph( + items = cardGraphTenItemPreviewItems, + selectedItemIndex = selectedItemIndex, + showDetail = showDetail, + useContainerStyle = useContainerStyle, + onSelectItem = { index -> + selectedItemIndex = if (selectedItemIndex == index) null else index + }, + ) + } + } +} 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..ceb6a239 --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt @@ -0,0 +1,334 @@ +package com.team.prezel.core.ui.component.graph + +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.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.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +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 DEFAULT_GOOD_LOWER_BOUND = 210 +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_CONTENT_WIDTH_RATIO = 0.625f +private val GRAPH_SIZE = 160.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(DEFAULT_GOOD_LOWER_BOUND, DEFAULT_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), + ) { + SpeedGraphContent( + userGauge = userGauge, + goodRange = goodRange, + baseRange = baseRange, + colors = colors, + ) + + RangeBoundLabels( + lowerBound = baseRange.first, + upperBound = baseRange.last, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +@Composable +private fun SpeedGraphContent( + userGauge: Int, + goodRange: IntRange, + baseRange: IntRange, + colors: SpeedGraphColors, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + SpeedGaugeArc( + userGauge = userGauge, + goodRange = goodRange, + baseRange = baseRange, + colors = colors, + ) + + 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 + + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val (arcTopLeft: Offset, arcSize: Size, arcStroke: Stroke) = size.calculateGraphArcMetrics() + + onDrawBehind { + drawBaseTrack( + trackColor = colors.baseTrackColor, + arcTopLeft = arcTopLeft, + arcSize = arcSize, + arcStroke = arcStroke, + ) + + if (!clampedGoodRange.isEmpty()) { + drawGoodRange( + rangeColor = colors.goodRangeColor, + goodRangeStartAngle = goodRangeStartAngle, + goodRangeSweepAngle = goodRangeSweepAngle, + arcTopLeft = arcTopLeft, + arcSize = arcSize, + arcStroke = 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) { + 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 RangeBoundLabels( + lowerBound: Int, + upperBound: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(GRAPH_LABEL_CONTENT_WIDTH_RATIO), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ProvideTextStyle( + value = PrezelTheme.typography.caption1Regular.copy(color = PrezelTheme.colors.textSmall), + ) { + Text(text = lowerBound.toString()) + Text(text = upperBound.toString()) + } + } +} + +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)) +} + +private fun Int.graphSweepAngleFromStart(baseRange: IntRange): Float = toGraphAngle(baseRange) - START_ANGLE + +private fun IntRange.graphSweepAngle(baseRange: IntRange): Float { + if (isEmpty()) return 0f + return max( + 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) + return if (start <= endInclusive) IntRange(start, endInclusive) else IntRange.EMPTY +} + +@BasicPreview +@Composable +private fun SpeedGraphGoodPreview() { + PrezelTheme { + SpeedGraph( + userGauge = 241, + modifier = Modifier.padding(16.dp), + ) + } +} + +@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), + ) + } +} 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..8d4658bf --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt @@ -0,0 +1,149 @@ +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.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.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.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +private const val STICK_GRAPH_MAX_HEIGHT = 160 +private val STICK_GRAPH_ITEM_WIDTH = 52.dp + +enum class StickGraphItemType { + SPELLING, + GRAMMAR, +} + +@Immutable +private data class StickGraphBar( + val count: Int, + val height: Dp, + val color: Color, + val label: String, +) + +@Composable +fun StickGraph( + data: ImmutableMap, + 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), + ) { + Text( + text = bar.count.toString(), + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textRegular, + ) + + 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, + ) + } +} + +@Composable +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 Int.toStickHeight(maxCount: Int): Dp { + if (maxCount <= 0) return 0.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 = + 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 { + Box( + modifier = Modifier.padding(12.dp), + ) { + StickGraph( + data = persistentMapOf( + StickGraphItemType.SPELLING to 2, + StickGraphItemType.GRAMMAR to 1, + ), + ) + } + } +} diff --git a/Prezel/core/ui/src/main/res/values/strings.xml b/Prezel/core/ui/src/main/res/values/strings.xml index cfeaeab7..32b5a672 100644 --- a/Prezel/core/ui/src/main/res/values/strings.xml +++ b/Prezel/core/ui/src/main/res/values/strings.xml @@ -7,4 +7,19 @@ 끝까지 해냄 컨디션 최고 감 잡았다 + + + 맞춤법 + 주술호응 + + + %1$d차 + 발화 + 대본 일치율 + + + 연습하기 + 이전 페이지 + 다음 페이지 + 연습한 횟수 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: 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 41f5109a..876acc79 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -62,6 +62,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",