diff --git a/app/src/main/java/com/cherrish/android/core/common/extension/ModifierExt.kt b/app/src/main/java/com/cherrish/android/core/common/extension/ModifierExt.kt index 222336552..3ba4c8a87 100644 --- a/app/src/main/java/com/cherrish/android/core/common/extension/ModifierExt.kt +++ b/app/src/main/java/com/cherrish/android/core/common/extension/ModifierExt.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.cherrish.android.core.designsystem.theme.CherrishTheme @Composable inline fun Modifier.noRippleClickable( @@ -45,7 +46,7 @@ fun Modifier.addFocusCleaner(focusManager: FocusManager): Modifier { @Composable fun Modifier.dropShadow( shape: Shape, - color: Color = Color.Black.copy(0.25f), + color: Color = CherrishTheme.colors.shadow, blur: Dp = 1.dp, offsetY: Dp = 1.dp, offsetX: Dp = 1.dp, diff --git a/app/src/main/java/com/cherrish/android/core/designsystem/component/type/CherrishGaugeType.kt b/app/src/main/java/com/cherrish/android/core/designsystem/component/type/CherrishGaugeType.kt index 6f3bc7b62..1feaa6e54 100644 --- a/app/src/main/java/com/cherrish/android/core/designsystem/component/type/CherrishGaugeType.kt +++ b/app/src/main/java/com/cherrish/android/core/designsystem/component/type/CherrishGaugeType.kt @@ -1,10 +1,15 @@ package com.cherrish.android.core.designsystem.component.type +import androidx.annotation.DrawableRes +import com.cherrish.android.R + enum class CherrishGaugeType( - val step: Int + val step: Int, + val percent: Int, + @DrawableRes val image: Int ) { - LEVEL1(1), - LEVEL2(2), - LEVEL3(3), - LEVEL4(4) + LEVEL1(step = 1, percent = 25, image = R.drawable.img_lv1), + LEVEL2(step = 2, percent = 50, image = R.drawable.img_lv2), + LEVEL3(step = 3, percent = 75, image = R.drawable.img_lv3), + LEVEL4(step = 4, percent = 100, image = R.drawable.img_lv4) } diff --git a/app/src/main/java/com/cherrish/android/core/designsystem/theme/Color.kt b/app/src/main/java/com/cherrish/android/core/designsystem/theme/Color.kt index 40ef956dc..9bf2ab1f6 100644 --- a/app/src/main/java/com/cherrish/android/core/designsystem/theme/Color.kt +++ b/app/src/main/java/com/cherrish/android/core/designsystem/theme/Color.kt @@ -37,6 +37,9 @@ val green3 = Color(0xFF9AD342) val shadow = Color(0xFF9098A7).copy(alpha = 0.12f) +val graStart = Color(0xFFFFF7f7) +val graEnd = Color(0xFFFFFDFD) + val bottomSheetScrimColor = Color(0x1A464C52) @Immutable @@ -69,6 +72,9 @@ data class CherrishColors( val shadow: Color, + val graStart: Color, + val graEnd: Color, + val bottomSheetScrimColor: Color ) @@ -101,6 +107,9 @@ val defaultCherrishColors = CherrishColors( shadow = shadow, + graStart = graStart, + graEnd = graEnd, + bottomSheetScrimColor = bottomSheetScrimColor ) @@ -222,6 +231,16 @@ private fun CherrishGrayColorsPreview() { style = CherrishTheme.typography.body1M14, color = CherrishTheme.colors.shadow ) + Text( + text = "GraStart", + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.graStart + ) + Text( + text = "GraEnd", + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.graEnd + ) Text( text = "BottomSheetScrimColor", style = CherrishTheme.typography.body1M14, diff --git a/app/src/main/java/com/cherrish/android/presentation/home/HomeScreen.kt b/app/src/main/java/com/cherrish/android/presentation/home/HomeScreen.kt index 4312e21f6..23d9d13a2 100644 --- a/app/src/main/java/com/cherrish/android/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/cherrish/android/presentation/home/HomeScreen.kt @@ -1,25 +1,138 @@ package com.cherrish.android.presentation.home +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +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.material3.Text +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.cherrish.android.core.common.state.UiState +import com.cherrish.android.core.designsystem.theme.CherrishTheme +import com.cherrish.android.core.designsystem.theme.graEnd +import com.cherrish.android.core.designsystem.theme.graStart +import com.cherrish.android.presentation.home.component.ChallengeSection +import com.cherrish.android.presentation.home.component.PlanBoxSection +import com.cherrish.android.presentation.home.component.UpcomingPlanSection +import java.time.LocalDate +import kotlinx.collections.immutable.persistentListOf @Composable fun HomeRoute( - paddingValues: PaddingValues + paddingValues: PaddingValues, + viewModel: HomeViewModel = hiltViewModel() ) { - HomeScreen(paddingValues = paddingValues) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + is UiState.Loading -> { + } + + is UiState.Failure -> { + } + + is UiState.Success -> { + HomeScreen( + uiState = state.data, + paddingValues = paddingValues, + onUpcomingPlanClick = viewModel::onUpcomingPlanClick, + onChallengeStartClick = viewModel::onAddChallengeClick, + onAddPlanClick = viewModel::onAddPlanClick + ) + } + + else -> {} + } } @Composable private fun HomeScreen( + uiState: HomeUiState, paddingValues: PaddingValues, + onUpcomingPlanClick: (LocalDate) -> Unit, + onChallengeStartClick: () -> Unit, + onAddPlanClick: () -> Unit, modifier: Modifier = Modifier ) { - Text( - text = "Home", - modifier = modifier.padding(paddingValues) - ) + Box( + modifier = modifier + .fillMaxSize() + .background(color = CherrishTheme.colors.graEnd) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(270.dp) + .background( + brush = Brush.verticalGradient( + colors = persistentListOf( + graStart, + graEnd + ) + ) + ) + ) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues( + start = 17.dp, + end = 17.dp, + top = 30.dp, + bottom = 20.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + ChallengeSection( + imageRes = uiState.gauges[uiState.selectedIndex].image, + currentStep = uiState.currentStep, + gauges = uiState.gauges, + onChallengeStartClick = onChallengeStartClick, + challengeName = uiState.challengeName + ) + } + + item { + PlanBoxSection( + todayDate = uiState.todayDate, + plans = uiState.plans + ) + } + + item { + UpcomingPlanSection( + onAddPlanClick = onAddPlanClick, + plans = uiState.upcomingPlans, + onUpcomingPlanClick = onUpcomingPlanClick + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + CherrishTheme { + HomeScreen( + uiState = HomeUiState.fake, + paddingValues = PaddingValues(0.dp), + onUpcomingPlanClick = {}, + onAddPlanClick = {}, + onChallengeStartClick = {} + ) + } } diff --git a/app/src/main/java/com/cherrish/android/presentation/home/HomeUiState.kt b/app/src/main/java/com/cherrish/android/presentation/home/HomeUiState.kt new file mode 100644 index 000000000..48fca48ac --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/HomeUiState.kt @@ -0,0 +1,53 @@ +package com.cherrish.android.presentation.home + +import androidx.compose.runtime.Immutable +import com.cherrish.android.core.designsystem.component.type.CherrishGaugeType +import com.cherrish.android.presentation.home.model.PlanUiModel +import com.cherrish.android.presentation.home.model.UpcomingPlanUiModel +import com.cherrish.android.presentation.home.type.DowntimePhase +import java.time.LocalDate +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Immutable +data class HomeUiState( + val currentStep: Int, + val gauges: ImmutableList, + val challengeName: String, + val todayDate: String, + val plans: ImmutableList, + val upcomingPlans: ImmutableList, + val selectedIndex: Int +) { + companion object { + val fake = HomeUiState( + currentStep = 1, + gauges = CherrishGaugeType.entries.toImmutableList(), + challengeName = "웰니스 • 마음챙김", + todayDate = "2026년 1월 1일 (목)", + plans = List(10) { + PlanUiModel( + procedureName = "슈링크", + daysSince = 2, + downtimePhase = DowntimePhase.SENSITIVE + ) + }.toImmutableList(), + upcomingPlans = persistentListOf( + UpcomingPlanUiModel( + upcomingPlanDate = LocalDate.now().plusDays(3), + procedureName = "써마지", + procedureCount = 2, + dDay = 3 + ), + UpcomingPlanUiModel( + upcomingPlanDate = LocalDate.now().plusDays(3), + procedureName = "써마지", + procedureCount = 2, + dDay = 3 + ) + ), + selectedIndex = 0 + ) + } +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/HomeViewModel.kt b/app/src/main/java/com/cherrish/android/presentation/home/HomeViewModel.kt new file mode 100644 index 000000000..75b18d321 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/HomeViewModel.kt @@ -0,0 +1,28 @@ +package com.cherrish.android.presentation.home + +import androidx.lifecycle.ViewModel +import com.cherrish.android.core.common.state.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +@HiltViewModel +class HomeViewModel @Inject constructor() : ViewModel() { + + private val _uiState = MutableStateFlow>( + UiState.Success(HomeUiState.fake) + ) + val uiState: StateFlow> = _uiState.asStateFlow() + + fun onUpcomingPlanClick(date: LocalDate) { + } + + fun onAddChallengeClick() { + } + + fun onAddPlanClick() { + } +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/component/ChallengeSection.kt b/app/src/main/java/com/cherrish/android/presentation/home/component/ChallengeSection.kt new file mode 100644 index 000000000..93c34ad76 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/component/ChallengeSection.kt @@ -0,0 +1,233 @@ +package com.cherrish.android.presentation.home.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.cherrish.android.R +import com.cherrish.android.core.common.extension.dropShadow +import com.cherrish.android.core.designsystem.component.button.CherrishButton +import com.cherrish.android.core.designsystem.component.gaugebar.CherrishGaugeBar +import com.cherrish.android.core.designsystem.component.type.CherrishGaugeType +import com.cherrish.android.core.designsystem.theme.CherrishTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun ChallengeSection( + gauges: ImmutableList, + modifier: Modifier = Modifier, + currentStep: Int = 0, + @DrawableRes imageRes: Int = R.drawable.img_lv1, + onChallengeStartClick: () -> Unit = {}, + challengeName: String = "웰니스 • 마음챙김" +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + if (currentStep != 0) { + Image( + painter = painterResource(id = imageRes), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.TopEnd) + .size(144.dp) + .offset(y = (-42).dp) + .zIndex(1f), + alignment = Alignment.TopEnd + ) + } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_app_logo_title), + contentDescription = null + ) + + Spacer(modifier = Modifier.height(10.dp)) + + if (currentStep == 0) { + NoChallenge( + currentStep = currentStep, + gauges = gauges, + onChallengeStartClick = onChallengeStartClick + ) + } else { + Challenge( + currentStep = currentStep, + gauges = gauges, + challengeName = challengeName + ) + } + } + } +} + +@Composable +private fun Challenge( + currentStep: Int, + gauges: ImmutableList, + challengeName: String, + modifier: Modifier = Modifier +) { + val safeStep = currentStep.coerceIn(1, gauges.size) + val currentPercent = gauges[safeStep - 1].percent + + Column( + modifier = modifier + .fillMaxWidth() + .dropShadow( + shape = RoundedCornerShape(14.dp), + blur = 10.dp, + offsetX = 0.dp, + offsetY = 0.dp, + spread = 0.dp + ) + .clip(shape = RoundedCornerShape(14.dp)) + .background(color = CherrishTheme.colors.gray0) + .padding(18.dp) + ) { + Text( + text = "진행중인 챌린지", + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray700 + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = challengeName, + style = CherrishTheme.typography.title2M16, + color = CherrishTheme.colors.gray900 + ) + + ChallengeChip( + percent = currentPercent + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + CherrishGaugeBar( + currentStep = safeStep, + gauges = gauges + ) + } +} + +@Composable +private fun ChallengeChip( + percent: Int, + modifier: Modifier = Modifier +) { + Text( + text = "$percent%", + style = CherrishTheme.typography.body3M12, + color = CherrishTheme.colors.red700, + modifier = modifier + .clip(shape = RoundedCornerShape(30.dp)) + .border( + width = 1.dp, + color = CherrishTheme.colors.red700, + shape = RoundedCornerShape(30.dp) + ) + .padding(horizontal = 7.dp, vertical = 1.dp) + ) +} + +@Composable +private fun NoChallenge( + currentStep: Int, + gauges: ImmutableList, + onChallengeStartClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .dropShadow( + shape = RoundedCornerShape(14.dp), + blur = 10.dp, + offsetX = 0.dp, + offsetY = 0.dp, + spread = 0.dp + ) + .clip(shape = RoundedCornerShape(14.dp)) + .background(color = CherrishTheme.colors.gray0) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "챌린지를 시작해봐요!", + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray700 + ) + + CherrishGaugeBar( + currentStep = currentStep, + gauges = gauges + ) + + CherrishButton( + text = "챌린지 시작하기", + onClick = onChallengeStartClick, + modifier = Modifier.padding(horizontal = 6.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview_ChallengeSection_NoChallenge() { + CherrishTheme { + ChallengeSection( + imageRes = R.drawable.img_lv1, + currentStep = 0, + gauges = CherrishGaugeType.entries.toImmutableList(), + onChallengeStartClick = {}, + challengeName = "아침 루틴" + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview_ChallengeSection_InProgress() { + CherrishTheme { + ChallengeSection( + imageRes = R.drawable.img_lv2, + currentStep = 2, + gauges = CherrishGaugeType.entries.toImmutableList(), + onChallengeStartClick = {}, + challengeName = "웰니스 • 마음챙김" + ) + } +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBox.kt b/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBox.kt new file mode 100644 index 000000000..ffe5ae237 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBox.kt @@ -0,0 +1,137 @@ +package com.cherrish.android.presentation.home.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cherrish.android.core.designsystem.theme.CherrishTheme +import com.cherrish.android.presentation.home.type.DowntimePhase + +@Composable +fun PlanBox( + state: PlanBoxState, + modifier: Modifier = Modifier +) { + val verticalPadding = when (state) { + PlanBoxState.Empty -> 12.dp + is PlanBoxState.Filled -> 13.dp + } + + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(10.dp)) + .border( + width = 1.dp, + color = CherrishTheme.colors.gray400, + shape = RoundedCornerShape(10.dp) + ) + .background(color = CherrishTheme.colors.gray0) + .padding(start = 14.dp) + .padding(vertical = verticalPadding) + ) { + when (state) { + PlanBoxState.Empty -> { + Text( + text = "진행 중인 일정이 없어요", + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray600 + ) + } + + is PlanBoxState.Filled -> { + PlanContent( + medicalProcedureName = state.medicalProcedureName, + medicalProcedureNameDate = state.medicalProcedureNameDate, + downtimePhase = state.downtimePhase + ) + } + } + } +} + +@Composable +private fun PlanContent( + medicalProcedureName: String, + medicalProcedureNameDate: Int, + downtimePhase: DowntimePhase, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = medicalProcedureName, + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray900 + ) + + Text( + text = "•", + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray900 + ) + + Text( + text = "회복 ${medicalProcedureNameDate}일차", + style = CherrishTheme.typography.body1R14, + color = CherrishTheme.colors.gray900 + ) + + DowntimePhaseChip( + downtimePhase = downtimePhase + ) + } +} + +@Composable +private fun DowntimePhaseChip( + downtimePhase: DowntimePhase, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(shape = RoundedCornerShape(20.dp)) + .background(color = CherrishTheme.colors.gray0) + .border( + width = 1.dp, + color = CherrishTheme.colors.gray400, + shape = RoundedCornerShape(20.dp) + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = downtimePhase.phaseName, + style = CherrishTheme.typography.body3R12, + color = CherrishTheme.colors.gray700 + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + CherrishTheme { + PlanBox( + state = PlanBoxState.Filled( + medicalProcedureName = "슈링크", + medicalProcedureNameDate = 2, + downtimePhase = DowntimePhase.SENSITIVE + ) + ) + } +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBoxSection.kt b/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBoxSection.kt new file mode 100644 index 000000000..a752c758e --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBoxSection.kt @@ -0,0 +1,146 @@ +package com.cherrish.android.presentation.home.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.cherrish.android.core.common.extension.dropShadow +import com.cherrish.android.core.common.extension.noRippleClickable +import com.cherrish.android.core.designsystem.theme.CherrishTheme +import com.cherrish.android.presentation.home.model.PlanUiModel +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun PlanBoxSection( + todayDate: String, + plans: ImmutableList, + modifier: Modifier = Modifier +) { + var expanded by rememberSaveable { mutableStateOf(false) } + + val previewCount = 3 + val hasMore = plans.size > previewCount + + val bottomPadding = if (plans.isEmpty()) 18.dp else 10.dp + + Column( + modifier = modifier + .fillMaxWidth() + .dropShadow( + shape = RoundedCornerShape(14.dp), + color = CherrishTheme.colors.shadow, + blur = 10.dp, + offsetX = 0.dp, + offsetY = 0.dp, + spread = 0.dp + ) + .clip(shape = RoundedCornerShape(14.dp)) + .background(color = CherrishTheme.colors.gray0) + .padding(horizontal = 15.dp) + .padding(top = 18.dp, bottom = bottomPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = todayDate, + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray700, + modifier = Modifier + .fillMaxWidth() + .align(alignment = Alignment.Start) + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (plans.isEmpty()) { + PlanBox(state = PlanBoxState.Empty) + } else { + plans.take(previewCount).forEachIndexed { index, plan -> + key("${plan.procedureName}-${plan.daysSince}-${plan.downtimePhase}-$index") { + PlanBox( + state = PlanBoxState.Filled( + medicalProcedureName = plan.procedureName, + medicalProcedureNameDate = plan.daysSince, + downtimePhase = plan.downtimePhase + ) + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween( + durationMillis = 400, + easing = FastOutLinearInEasing + ) + ) + fadeIn(), + exit = shrinkVertically( + shrinkTowards = Alignment.Bottom, + animationSpec = tween( + durationMillis = 400, + easing = FastOutSlowInEasing + ) + ) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + plans.drop(previewCount).forEachIndexed { _, plan -> + key( + "${plan.procedureName}-${plan.daysSince}-${plan.downtimePhase}" + ) { + PlanBox( + state = PlanBoxState.Filled( + medicalProcedureName = plan.procedureName, + medicalProcedureNameDate = plan.daysSince, + downtimePhase = plan.downtimePhase + ) + ) + } + } + } + } + } + } + + if (hasMore) { + Text( + text = if (expanded) "접기" else "더보기", + style = CherrishTheme.typography.body2R13, + color = CherrishTheme.colors.gray500, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .noRippleClickable { + expanded = !expanded + } + ) + } + } +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBoxState.kt b/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBoxState.kt new file mode 100644 index 000000000..a11797750 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/component/PlanBoxState.kt @@ -0,0 +1,12 @@ +package com.cherrish.android.presentation.home.component + +import com.cherrish.android.presentation.home.type.DowntimePhase + +sealed interface PlanBoxState { + data object Empty : PlanBoxState + data class Filled( + val medicalProcedureName: String, + val medicalProcedureNameDate: Int, + val downtimePhase: DowntimePhase + ) : PlanBoxState +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/component/UpcomingPlanSection.kt b/app/src/main/java/com/cherrish/android/presentation/home/component/UpcomingPlanSection.kt new file mode 100644 index 000000000..1a182a6d1 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/component/UpcomingPlanSection.kt @@ -0,0 +1,386 @@ +package com.cherrish.android.presentation.home.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +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.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.geometry.Size +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cherrish.android.R +import com.cherrish.android.core.common.extension.dropShadow +import com.cherrish.android.core.common.extension.noRippleClickable +import com.cherrish.android.core.designsystem.component.button.CherrishButton +import com.cherrish.android.core.designsystem.theme.CherrishTheme +import com.cherrish.android.presentation.home.model.UpcomingPlanUiModel +import com.cherrish.android.presentation.home.type.UpcomingPlanTimelineType +import com.cherrish.android.presentation.home.type.style +import com.cherrish.android.presentation.home.type.toUpcomingPlanTimelineType +import java.time.LocalDate +import kotlin.math.abs +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun UpcomingPlanSection( + onAddPlanClick: () -> Unit, + plans: ImmutableList, + onUpcomingPlanClick: (LocalDate) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .dropShadow( + shape = RoundedCornerShape(10.dp), + color = CherrishTheme.colors.shadow, + blur = 10.dp, + offsetX = 0.dp, + offsetY = 0.dp, + spread = 0.dp + ) + .clip(shape = RoundedCornerShape(14.dp)) + .background(color = CherrishTheme.colors.gray0) + .padding(horizontal = 15.dp, vertical = 16.dp) + ) { + Text( + text = "다가오는 일정", + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray700, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 5.dp) + ) + + HorizontalDivider( + thickness = 1.dp, + color = CherrishTheme.colors.gray300 + ) + + when { + plans.isEmpty() -> { + UpcomingNoPlan( + onAddPlanClick = onAddPlanClick + ) + } + + else -> { + UpcomingPlan( + plans = plans, + onUpcomingPlanClick = onUpcomingPlanClick + ) + } + } + } +} + +@Composable +private fun UpcomingPlan( + plans: ImmutableList, + onUpcomingPlanClick: (LocalDate) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(start = 26.dp, end = 16.dp, top = 11.dp) + ) { + plans.forEachIndexed { index, plan -> + key("${plan.upcomingPlanDate}-$index") { + UpcomingPlanContent( + planCount = plans.size, + planModel = plan, + index = index, + onUpcomingPlanClick = onUpcomingPlanClick, + type = index.toUpcomingPlanTimelineType() + ) + } + } + } +} + +@Composable +private fun UpcomingPlanContent( + planCount: Int, + planModel: UpcomingPlanUiModel, + index: Int, + onUpcomingPlanClick: (LocalDate) -> Unit, + type: UpcomingPlanTimelineType, + modifier: Modifier = Modifier +) { + var heightPx by remember { mutableIntStateOf(0) } + var dateTextTopPx by remember { mutableFloatStateOf(0f) } + + Row( + modifier = modifier + .wrapContentHeight() + .onSizeChanged { heightPx = it.height } + .noRippleClickable(onClick = { onUpcomingPlanClick(planModel.upcomingPlanDate) }), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.Top + ) { + UpcomingPlanTimeline( + planCount = planCount, + type = type, + heightPx = heightPx, + isFirst = index == 0, + dotTopPx = dateTextTopPx + ) + + UpcomingPlanBox( + upcomingDate = planModel.upcomingPlanDate, + procedureName = planModel.procedureName, + procedureCount = planModel.procedureCount, + dDay = planModel.dDay, + onDateTextTopPositioned = { topPx -> dateTextTopPx = topPx } + ) + } +} + +@Composable +private fun UpcomingPlanTimeline( + planCount: Int, + type: UpcomingPlanTimelineType, + heightPx: Int, + isFirst: Boolean, + dotTopPx: Float, + modifier: Modifier = Modifier +) { + val timelineStyle = type.style(size = planCount, colors = CherrishTheme.colors) + val density = LocalDensity.current + + val totalHeightDp = with(density) { heightPx.toDp() } + + val circleSize = 10.dp + val lineWidth = 2.dp + + Box( + modifier = modifier + .height(totalHeightDp) + .width(circleSize), + contentAlignment = Alignment.TopCenter + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val stroke = with(density) { lineWidth.toPx() } + val radius = with(density) { (circleSize / 2).toPx() } + val centerX = size.width / 2f + + val dotTopY = dotTopPx + val circleCenterY = dotTopY + radius + val lineTopY = if (isFirst) dotTopY else 0f + drawRect( + brush = timelineStyle.barBrush, + topLeft = Offset(centerX - stroke / 2f, lineTopY), + size = Size(stroke, size.height - lineTopY) + ) + + drawCircle( + color = timelineStyle.circleColor, + radius = radius, + center = Offset(centerX, circleCenterY) + ) + } + } +} + +@Composable +private fun UpcomingPlanBox( + upcomingDate: LocalDate, + procedureName: String, + procedureCount: Int, + dDay: Int, + onDateTextTopPositioned: (Float) -> Unit, + modifier: Modifier = Modifier +) { + var previousDateTextTopY by remember { mutableFloatStateOf(Float.NaN) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding(top = 5.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = upcomingDate.toString(), + style = CherrishTheme.typography.body1M14, + color = CherrishTheme.colors.gray700, + modifier = Modifier.onGloballyPositioned { coordinates -> + val y = coordinates.positionInParent().y + if (previousDateTextTopY.isNaN() || abs(y - previousDateTextTopY) > 0.5f) { + previousDateTextTopY = y + onDateTextTopPositioned(y) + } + } + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = procedureName, + style = CherrishTheme.typography.title2M16, + color = CherrishTheme.colors.gray900 + ) + + if (procedureCount != 0) { + Text( + text = "외 ${procedureCount}개", + style = CherrishTheme.typography.body1R14, + color = CherrishTheme.colors.gray600 + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + DDayChip( + dDay = dDay + ) + + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_right), + contentDescription = null, + tint = CherrishTheme.colors.gray600 + ) + } + } +} + +@Composable +private fun DDayChip( + dDay: Int, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(shape = RoundedCornerShape(20.dp)) + .background(color = CherrishTheme.colors.gray200) + .padding(horizontal = 10.dp) + ) { + Text( + text = "D-$dDay", + style = CherrishTheme.typography.body3R12, + color = CherrishTheme.colors.gray600 + ) + } +} + +@Composable +private fun UpcomingNoPlan( + onAddPlanClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 9.dp) + .padding(bottom = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Image( + painter = painterResource(id = R.drawable.img_home_no_plan), + contentDescription = null, + modifier = Modifier.size(width = 98.dp, height = 80.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "아직 진행 중인 관리가 없어요.", + style = CherrishTheme.typography.body1R14, + color = CherrishTheme.colors.gray600, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + CherrishButton( + text = "관리 일정을 추가하기", + onClick = onAddPlanClick + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview_UpcomingPlanSection_Empty() { + CherrishTheme { + UpcomingPlanSection( + onAddPlanClick = {}, + plans = persistentListOf(), + onUpcomingPlanClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview_UpcomingPlanSection_Filled() { + CherrishTheme { + UpcomingPlanSection( + onAddPlanClick = {}, + plans = persistentListOf( + UpcomingPlanUiModel( + upcomingPlanDate = LocalDate.of(2026, 1, 20), + procedureName = "슈링크", + procedureCount = 1, + dDay = 3 + ), + UpcomingPlanUiModel( + upcomingPlanDate = LocalDate.of(2026, 1, 25), + procedureName = "보톡스", + procedureCount = 0, + dDay = 8 + ), + UpcomingPlanUiModel( + upcomingPlanDate = LocalDate.of(2026, 2, 1), + procedureName = "필러", + procedureCount = 2, + dDay = 15 + ) + ), + onUpcomingPlanClick = {} + ) + } +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/model/PlanUiModel.kt b/app/src/main/java/com/cherrish/android/presentation/home/model/PlanUiModel.kt new file mode 100644 index 000000000..5311332ac --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/model/PlanUiModel.kt @@ -0,0 +1,11 @@ +package com.cherrish.android.presentation.home.model + +import androidx.compose.runtime.Immutable +import com.cherrish.android.presentation.home.type.DowntimePhase + +@Immutable +data class PlanUiModel( + val procedureName: String, + val daysSince: Int, + val downtimePhase: DowntimePhase +) diff --git a/app/src/main/java/com/cherrish/android/presentation/home/model/UpcomingPlanUiModel.kt b/app/src/main/java/com/cherrish/android/presentation/home/model/UpcomingPlanUiModel.kt new file mode 100644 index 000000000..4006c68ed --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/model/UpcomingPlanUiModel.kt @@ -0,0 +1,12 @@ +package com.cherrish.android.presentation.home.model + +import androidx.compose.runtime.Immutable +import java.time.LocalDate + +@Immutable +data class UpcomingPlanUiModel( + val upcomingPlanDate: LocalDate, + val procedureName: String, + val procedureCount: Int, + val dDay: Int +) diff --git a/app/src/main/java/com/cherrish/android/presentation/home/type/DowntimePhase.kt b/app/src/main/java/com/cherrish/android/presentation/home/type/DowntimePhase.kt new file mode 100644 index 000000000..481dbfc96 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/type/DowntimePhase.kt @@ -0,0 +1,9 @@ +package com.cherrish.android.presentation.home.type + +enum class DowntimePhase( + val phaseName: String +) { + SENSITIVE("민감기"), + CAUTION("주의기"), + RECOVERY("회복기") +} diff --git a/app/src/main/java/com/cherrish/android/presentation/home/type/UpcomingPlanTimelineStyle.kt b/app/src/main/java/com/cherrish/android/presentation/home/type/UpcomingPlanTimelineStyle.kt new file mode 100644 index 000000000..d373de010 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/type/UpcomingPlanTimelineStyle.kt @@ -0,0 +1,34 @@ +package com.cherrish.android.presentation.home.type + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.cherrish.android.core.designsystem.theme.CherrishColors +import com.cherrish.android.core.designsystem.theme.gray0 +import kotlinx.collections.immutable.persistentListOf + +data class UpcomingPlanTimelineStyle( + val circleColor: Color, + val barBrush: Brush +) + +fun UpcomingPlanTimelineType.style(size: Int, colors: CherrishColors): UpcomingPlanTimelineStyle = + when (this) { + UpcomingPlanTimelineType.FIRST -> UpcomingPlanTimelineStyle( + circleColor = colors.red600, + barBrush = Brush.linearGradient( + persistentListOf(colors.red600, if (size >= 2) colors.red500 else colors.gray0) + ) + ) + + UpcomingPlanTimelineType.SECOND -> UpcomingPlanTimelineStyle( + circleColor = colors.red500, + barBrush = Brush.linearGradient( + persistentListOf(colors.red500, if (size >= 3) colors.red300 else colors.gray0) + ) + ) + + UpcomingPlanTimelineType.THIRD -> UpcomingPlanTimelineStyle( + circleColor = colors.red300, + barBrush = Brush.linearGradient(persistentListOf(colors.red300, colors.gray0)) + ) + } diff --git a/app/src/main/java/com/cherrish/android/presentation/home/type/UpcomingPlanTimelineType.kt b/app/src/main/java/com/cherrish/android/presentation/home/type/UpcomingPlanTimelineType.kt new file mode 100644 index 000000000..3a84c0de4 --- /dev/null +++ b/app/src/main/java/com/cherrish/android/presentation/home/type/UpcomingPlanTimelineType.kt @@ -0,0 +1,11 @@ +package com.cherrish.android.presentation.home.type + +enum class UpcomingPlanTimelineType { + FIRST, SECOND, THIRD +} + +fun Int.toUpcomingPlanTimelineType(): UpcomingPlanTimelineType = when (this) { + 0 -> UpcomingPlanTimelineType.FIRST + 1 -> UpcomingPlanTimelineType.SECOND + else -> UpcomingPlanTimelineType.THIRD +} diff --git a/app/src/main/res/drawable/ic_app_logo_title.xml b/app/src/main/res/drawable/ic_app_logo_title.xml new file mode 100644 index 000000000..0b666795f --- /dev/null +++ b/app/src/main/res/drawable/ic_app_logo_title.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_right.xml b/app/src/main/res/drawable/ic_right.xml new file mode 100644 index 000000000..af02760dd --- /dev/null +++ b/app/src/main/res/drawable/ic_right.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/img_home_cherry.png b/app/src/main/res/drawable/img_home_cherry.png new file mode 100644 index 000000000..90c65c125 Binary files /dev/null and b/app/src/main/res/drawable/img_home_cherry.png differ diff --git a/app/src/main/res/drawable/img_home_no_plan.png b/app/src/main/res/drawable/img_home_no_plan.png new file mode 100644 index 000000000..659fca251 Binary files /dev/null and b/app/src/main/res/drawable/img_home_no_plan.png differ diff --git a/app/src/main/res/drawable/img_lv0.png b/app/src/main/res/drawable/img_lv0.png new file mode 100644 index 000000000..d8d6ec23e Binary files /dev/null and b/app/src/main/res/drawable/img_lv0.png differ diff --git a/app/src/main/res/drawable/img_lv1.png b/app/src/main/res/drawable/img_lv1.png new file mode 100644 index 000000000..4416b77fa Binary files /dev/null and b/app/src/main/res/drawable/img_lv1.png differ diff --git a/app/src/main/res/drawable/img_lv2.png b/app/src/main/res/drawable/img_lv2.png new file mode 100644 index 000000000..547786a59 Binary files /dev/null and b/app/src/main/res/drawable/img_lv2.png differ diff --git a/app/src/main/res/drawable/img_lv3.png b/app/src/main/res/drawable/img_lv3.png new file mode 100644 index 000000000..89555f3e9 Binary files /dev/null and b/app/src/main/res/drawable/img_lv3.png differ diff --git a/app/src/main/res/drawable/img_lv4.png b/app/src/main/res/drawable/img_lv4.png new file mode 100644 index 000000000..459c76779 Binary files /dev/null and b/app/src/main/res/drawable/img_lv4.png differ