diff --git a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/typography/Type.kt b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/typography/Type.kt index fb304e13..b6fae062 100644 --- a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/typography/Type.kt +++ b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/typography/Type.kt @@ -101,6 +101,13 @@ class BitnagilTypography internal constructor( lineHeight = 30, letterSpacing = (-0.5f), ), + private val _cafe24SsurroundAir2: BitnagilTextStyle = BitnagilTextStyle( + fontFamily = cafe24SsurroundAir, + fontWeight = FontWeight.Light, + fontSize = 16, + lineHeight = 24, + letterSpacing = (-0.5f), + ), ) { private val _headline1Bold = _headline1.copy(fontWeight = FontWeight.Bold) private val _headline1Medium = _headline1.copy(fontWeight = FontWeight.Medium) @@ -163,6 +170,7 @@ class BitnagilTypography internal constructor( val button1: TextStyle @Composable get() = _button1.toDpTextStyle val button2: TextStyle @Composable get() = _button2.toDpTextStyle val cafe24SsurroundAir: TextStyle @Composable get() = _cafe24SsurroundAir.toDpTextStyle + val cafe24SsurroundAir2: TextStyle @Composable get() = _cafe24SsurroundAir2.toDpTextStyle } internal val LocalBitnagilTypography = staticCompositionLocalOf { BitnagilTypography() } diff --git a/core/designsystem/src/main/res/drawable-hdpi/img_ground.png b/core/designsystem/src/main/res/drawable-hdpi/img_ground.png new file mode 100644 index 00000000..dfa97bde Binary files /dev/null and b/core/designsystem/src/main/res/drawable-hdpi/img_ground.png differ diff --git a/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo.png b/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo.png new file mode 100644 index 00000000..792cb974 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo.png differ diff --git a/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo_left_hand.png b/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo_left_hand.png new file mode 100644 index 00000000..75b024b0 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo_left_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo_right_hand.png b/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo_right_hand.png new file mode 100644 index 00000000..c919fde2 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-hdpi/img_marble_pomo_right_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-hdpi/img_pomo_hand.png b/core/designsystem/src/main/res/drawable-hdpi/img_pomo_hand.png new file mode 100644 index 00000000..0ade69b2 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-hdpi/img_pomo_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-hdpi/img_pomo_thumb.png b/core/designsystem/src/main/res/drawable-hdpi/img_pomo_thumb.png new file mode 100644 index 00000000..bb860b73 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-hdpi/img_pomo_thumb.png differ diff --git a/core/designsystem/src/main/res/drawable-mdpi/img_ground.png b/core/designsystem/src/main/res/drawable-mdpi/img_ground.png new file mode 100644 index 00000000..1b902aeb Binary files /dev/null and b/core/designsystem/src/main/res/drawable-mdpi/img_ground.png differ diff --git a/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo.png b/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo.png new file mode 100644 index 00000000..b41eaf33 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo.png differ diff --git a/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo_left_hand.png b/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo_left_hand.png new file mode 100644 index 00000000..86e9b387 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo_left_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo_right_hand.png b/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo_right_hand.png new file mode 100644 index 00000000..98dc0131 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-mdpi/img_marble_pomo_right_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-mdpi/img_pomo_hand.png b/core/designsystem/src/main/res/drawable-mdpi/img_pomo_hand.png new file mode 100644 index 00000000..bd6f21ae Binary files /dev/null and b/core/designsystem/src/main/res/drawable-mdpi/img_pomo_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-mdpi/img_pomo_thumb.png b/core/designsystem/src/main/res/drawable-mdpi/img_pomo_thumb.png new file mode 100644 index 00000000..ce53a107 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-mdpi/img_pomo_thumb.png differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_ground.png b/core/designsystem/src/main/res/drawable-xhdpi/img_ground.png new file mode 100644 index 00000000..edfb908d Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_ground.png differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo.png b/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo.png new file mode 100644 index 00000000..6a6f004f Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo.png differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo_left_hand.png b/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo_left_hand.png new file mode 100644 index 00000000..dd7235a3 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo_left_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo_right_hand.png b/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo_right_hand.png new file mode 100644 index 00000000..52e9805d Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_marble_pomo_right_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_hand.png b/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_hand.png new file mode 100644 index 00000000..463aea54 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_thumb.png b/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_thumb.png new file mode 100644 index 00000000..cdc7a32c Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_pomo_thumb.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_ground.png b/core/designsystem/src/main/res/drawable-xxhdpi/img_ground.png new file mode 100644 index 00000000..4913c427 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_ground.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo.png b/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo.png new file mode 100644 index 00000000..96074d79 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo_left_hand.png b/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo_left_hand.png new file mode 100644 index 00000000..19fbf123 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo_left_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo_right_hand.png b/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo_right_hand.png new file mode 100644 index 00000000..abd3c12a Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_marble_pomo_right_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_hand.png b/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_hand.png new file mode 100644 index 00000000..fa2d6c0f Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_thumb.png b/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_thumb.png new file mode 100644 index 00000000..f193a641 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_pomo_thumb.png differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_ground.png b/core/designsystem/src/main/res/drawable-xxxhdpi/img_ground.png new file mode 100644 index 00000000..29223375 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_ground.png differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo.png b/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo.png new file mode 100644 index 00000000..ba23b024 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo.png differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo_left_hand.png b/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo_left_hand.png new file mode 100644 index 00000000..872ef5be Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo_left_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo_right_hand.png b/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo_right_hand.png new file mode 100644 index 00000000..418d664c Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_marble_pomo_right_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_hand.png b/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_hand.png new file mode 100644 index 00000000..7f66eee4 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_hand.png differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_thumb.png b/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_thumb.png new file mode 100644 index 00000000..6be2bfd2 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_pomo_thumb.png differ diff --git a/core/designsystem/src/main/res/drawable/ic_double_down_arrow_24.xml b/core/designsystem/src/main/res/drawable/ic_double_down_arrow_24.xml new file mode 100644 index 00000000..03621063 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_double_down_arrow_24.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_double_left_arrow_24.xml b/core/designsystem/src/main/res/drawable/ic_double_left_arrow_24.xml new file mode 100644 index 00000000..e52330f7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_double_left_arrow_24.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_double_right_arrow_24.xml b/core/designsystem/src/main/res/drawable/ic_double_right_arrow_24.xml new file mode 100644 index 00000000..23398e2f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_double_right_arrow_24.xml @@ -0,0 +1,20 @@ + + + + diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/dimension/DpToPx.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/dimension/DpToPx.kt new file mode 100644 index 00000000..bd006739 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/dimension/DpToPx.kt @@ -0,0 +1,8 @@ +package com.threegap.bitnagil.presentation.common.dimension + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + +@Composable +fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.toPx() } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/common/dimension/PxToDp.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/dimension/PxToDp.kt new file mode 100644 index 00000000..9ef3348d --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/common/dimension/PxToDp.kt @@ -0,0 +1,10 @@ +package com.threegap.bitnagil.presentation.common.dimension + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity + +@Composable +fun Float.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } + +@Composable +fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionScreen.kt index f1f4bd68..c8e09327 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionScreen.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionScreen.kt @@ -1,50 +1,23 @@ package com.threegap.bitnagil.presentation.emotion import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade -import com.threegap.bitnagil.designsystem.BitnagilTheme -import com.threegap.bitnagil.designsystem.component.atom.BitnagilSelectButton -import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButton -import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButtonColor -import com.threegap.bitnagil.designsystem.component.block.BitnagilTopBar -import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple +import com.threegap.bitnagil.presentation.common.dimension.pxToDp import com.threegap.bitnagil.presentation.common.flow.collectAsEffect -import com.threegap.bitnagil.presentation.emotion.model.EmotionRecommendRoutineUiModel +import com.threegap.bitnagil.presentation.common.toast.GlobalBitnagilToast +import com.threegap.bitnagil.presentation.emotion.component.template.EmotionRecommendRoutineScreen +import com.threegap.bitnagil.presentation.emotion.component.template.SimpleEmotionSelectionScreen +import com.threegap.bitnagil.presentation.emotion.component.template.SwipeEmotionSelectionScreen import com.threegap.bitnagil.presentation.emotion.model.EmotionScreenStep -import com.threegap.bitnagil.presentation.emotion.model.EmotionUiModel import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionSideEffect -import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionState @Composable fun EmotionScreenContainer( @@ -60,15 +33,32 @@ fun EmotionScreenContainer( viewModel.sideEffectFlow.collectAsEffect { sideEffect -> when (sideEffect) { EmotionSideEffect.NavigateToBack -> navigateToBack() + is EmotionSideEffect.ShowToast -> GlobalBitnagilToast.showWarning(sideEffect.message) } } when (state.step) { - EmotionScreenStep.Emotion -> EmotionScreen( - state = state, - onClickPreviousButton = navigateToBack, - onClickEmotion = viewModel::selectEmotion, - ) + EmotionScreenStep.Emotion -> BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val height = constraints.maxHeight.pxToDp() + + if (height > 600.dp) { + SwipeEmotionSelectionScreen( + state = state, + onClickPreviousButton = navigateToBack, + onSelectEmotion = remember { + { emotionType -> + viewModel.selectEmotion(emotionType = emotionType, minimumDelay = 1000) + } + }, + ) + } else { + SimpleEmotionSelectionScreen( + state = state, + onClickPreviousButton = navigateToBack, + onClickEmotion = viewModel::selectEmotion, + ) + } + } EmotionScreenStep.RecommendRoutines -> EmotionRecommendRoutineScreen( state = state, onClickRoutine = viewModel::selectRecommendRoutine, @@ -77,208 +67,3 @@ fun EmotionScreenContainer( ) } } - -@Composable -private fun EmotionScreen( - state: EmotionState, - onClickPreviousButton: () -> Unit, - onClickEmotion: (String) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .background(color = BitnagilTheme.colors.white) - .statusBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - BitnagilTopBar( - showBackButton = true, - onBackClick = onClickPreviousButton, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - "오늘의 감정구슬을 골라보세요", - style = BitnagilTheme.typography.title2Bold.copy(color = BitnagilTheme.colors.navy500), - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(6.dp)) - - Text( - "감정구슬을 등록하면 루틴을 추천받아요!", - style = BitnagilTheme.typography.subtitle1Regular.copy(color = BitnagilTheme.colors.navy300), - textAlign = TextAlign.Center, - ) - - Spacer(modifier = Modifier.height(64.dp)) - - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = Modifier.padding(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(28.dp), - ) { - items(state.emotionTypeUiModels) { emotion -> - Column( - modifier = Modifier - .clickableWithoutRipple { onClickEmotion(emotion.emotionType) }, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(emotion.imageUrl) - .crossfade(true) - .build(), - modifier = Modifier.aspectRatio(1f), - contentDescription = null, - error = emotion.offlineBackupImageResourceId?.let { painterResource(it) }, - ) - - Text( - text = emotion.emotionMarbleName, - style = BitnagilTheme.typography.body1Regular.copy(color = BitnagilTheme.colors.coolGray20), - ) - } - } - } - } -} - -@Composable -private fun EmotionRecommendRoutineScreen( - state: EmotionState, - onClickRoutine: (String) -> Unit, - onClickRegisterRecommendRoutines: () -> Unit, - onClickSkip: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .background(color = BitnagilTheme.colors.coolGray99) - .statusBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 20.dp, top = 32.dp), - ) { - Spacer(modifier = Modifier.height(54.dp)) - - Text( - text = "오늘 감정에 따른\n루틴을 추천드릴께요!", - color = BitnagilTheme.colors.navy500, - style = BitnagilTheme.typography.title2Bold, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - Text( - text = "오늘 당신의 감정 상태에 맞춰 구성된 맞춤 루틴이에요.\n원하는 루틴을 선택해서 가볍게 시작해보세요.", - color = BitnagilTheme.colors.coolGray50, - style = BitnagilTheme.typography.body2Medium, - ) - - Spacer(modifier = Modifier.height(28.dp)) - - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(state = scrollState), - ) { - for (recommendRoutine in state.recommendRoutines) { - BitnagilSelectButton( - title = recommendRoutine.name, - description = recommendRoutine.description, - onClick = { onClickRoutine(recommendRoutine.id) }, - selected = recommendRoutine.selected, - modifier = Modifier.padding(bottom = 12.dp), - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - BitnagilTextButton( - text = "변경하기", - onClick = onClickRegisterRecommendRoutines, - enabled = state.registerRecommendRoutinesButtonEnabled, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(10.dp)) - - BitnagilTextButton( - text = "건너뛰기", - onClick = onClickSkip, - colors = BitnagilTextButtonColor.skip().copy( - defaultBackgroundColor = Color.Transparent, - pressedBackgroundColor = Color.Transparent, - disabledBackgroundColor = Color.Transparent, - ), - textStyle = BitnagilTheme.typography.body2Regular, - textDecoration = TextDecoration.Underline, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@Preview -@Composable -private fun EmotionScreenPreview() { - BitnagilTheme { - EmotionScreen( - state = EmotionState( - emotionTypeUiModels = listOf( - EmotionUiModel( - emotionType = "emotionType", - imageUrl = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", - emotionMarbleName = "emotionMarbleName", - offlineBackupImageResourceId = null, - ), - EmotionUiModel( - emotionType = "emotionType", - imageUrl = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", - emotionMarbleName = "emotionMarbleName", - offlineBackupImageResourceId = null, - ), - ), - isLoading = false, - step = EmotionScreenStep.Emotion, - recommendRoutines = listOf(), - ), - onClickEmotion = { _ -> }, - onClickPreviousButton = {}, - ) - } -} - -@Preview -@Composable -private fun EmotionRecommendRoutineScreenPreview() { - BitnagilTheme { - EmotionRecommendRoutineScreen( - state = EmotionState( - emotionTypeUiModels = listOf( - EmotionUiModel( - emotionType = "emotionType", - imageUrl = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", - emotionMarbleName = "emotionMarbleName", - offlineBackupImageResourceId = null, - ), - ), - isLoading = false, - step = EmotionScreenStep.RecommendRoutines, - recommendRoutines = listOf( - EmotionRecommendRoutineUiModel( - id = "1", - name = "루틴 이름", - description = "루틴 설명", - selected = true, - ), - ), - ), - onClickRoutine = {}, - onClickRegisterRecommendRoutines = {}, - onClickSkip = {}, - ) - } -} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionViewModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionViewModel.kt index 5e95dd86..72cf4913 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionViewModel.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/EmotionViewModel.kt @@ -13,6 +13,7 @@ import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionIntent import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionSideEffect import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.orbitmvi.orbit.syntax.simple.SimpleSyntax import javax.inject.Inject @@ -59,11 +60,13 @@ class EmotionViewModel @Inject constructor( recommendRoutines = intent.recommendRoutines, step = EmotionScreenStep.RecommendRoutines, isLoading = false, + showLoadingView = false, ) } EmotionIntent.RegisterEmotionLoading -> { return state.copy( isLoading = true, + showLoadingView = true, ) } EmotionIntent.RegisterRecommendRoutinesLoading -> { @@ -103,22 +106,35 @@ class EmotionViewModel @Inject constructor( sendSideEffect(EmotionSideEffect.NavigateToBack) return null } + + is EmotionIntent.RegisterEmotionFailure -> { + sendSideEffect(EmotionSideEffect.ShowToast(intent.message)) + sendSideEffect(EmotionSideEffect.NavigateToBack) + return null + } } } - fun selectEmotion(emotionType: String) { + fun selectEmotion(emotionType: String, minimumDelay: Long = 0) { val isLoading = stateFlow.value.isLoading if (isLoading) return viewModelScope.launch { sendIntent(EmotionIntent.RegisterEmotionLoading) + + if (minimumDelay > 0) { + delay(minimumDelay) + } + registerEmotionUseCase(emotionType = emotionType).fold( onSuccess = { emotionRecommendRoutines -> val recommendRoutines = emotionRecommendRoutines.map { EmotionRecommendRoutineUiModel.fromEmotionRecommendRoutine(it) } sendIntent(EmotionIntent.RegisterEmotionSuccess(recommendRoutines)) }, onFailure = { - // todo 실패 케이스 정의되면 처리 + sendIntent( + EmotionIntent.RegisterEmotionFailure(message = it.message ?: "에러가 발생했습니다. 잠시 후 시도해주세요."), + ) }, ) } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/atom/EmotionMarbleImage.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/atom/EmotionMarbleImage.kt new file mode 100644 index 00000000..3bf89cd4 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/atom/EmotionMarbleImage.kt @@ -0,0 +1,42 @@ +package com.threegap.bitnagil.presentation.emotion.component.atom + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.threegap.bitnagil.presentation.emotion.model.EmotionImageUiModel + +@Composable +fun EmotionMarbleImage( + modifier: Modifier, + image: EmotionImageUiModel, + contentDescription: String? = null, + alpha: Float = 1f, +) { + when (image) { + is EmotionImageUiModel.Url -> { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(image.url) + .crossfade(true) + .build(), + modifier = modifier, + contentDescription = contentDescription, + alpha = alpha, + error = image.offlineBackupImageResourceId?.let { painterResource(it) }, + ) + } + is EmotionImageUiModel.Resource -> { + Image( + painter = painterResource(id = image.resourceId), + contentDescription = contentDescription, + modifier = modifier, + alpha = alpha, + ) + } + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/atom/SpeechBubbleText.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/atom/SpeechBubbleText.kt new file mode 100644 index 00000000..1d90b7a7 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/atom/SpeechBubbleText.kt @@ -0,0 +1,87 @@ +package com.threegap.bitnagil.presentation.emotion.component.atom + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme + +@Composable +fun SpeechBubbleText( + text: String, + backgroundColor: Long, + textColor: Long, + modifier: Modifier = Modifier, + maxLines: Int = 2, + minLines: Int = 2, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .background(Color(backgroundColor), shape = RoundedCornerShape(12.dp)) + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = BitnagilTheme.typography.cafe24SsurroundAir2.copy(color = Color(textColor)), + textAlign = TextAlign.Center, + maxLines = maxLines, + minLines = minLines, + ) + } + + Canvas( + modifier = Modifier + .height(10.dp) + .width(24.dp), + ) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width / 2, size.height) + lineTo(0f, 0f) + close() + } + drawPath(path, color = Color(backgroundColor)) + } + } +} + +@Preview +@Composable +private fun SpeechBubbleTextPreview() { + BitnagilTheme { + Column { + SpeechBubbleText( + text = "hi", + backgroundColor = 0xFFFFFFFF, + textColor = 0xFF000000, + minLines = 1, + ) + + SpeechBubbleText( + text = "안녕하세요?", + backgroundColor = 0xFF000000, + textColor = 0xFFFFFFFF, + minLines = 1, + ) + } + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/EmotionLoadingView.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/EmotionLoadingView.kt new file mode 100644 index 00000000..fb58ed21 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/EmotionLoadingView.kt @@ -0,0 +1,190 @@ +package com.threegap.bitnagil.presentation.emotion.component.template + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.presentation.common.dimension.pxToDp +import com.threegap.bitnagil.presentation.emotion.component.atom.EmotionMarbleImage +import com.threegap.bitnagil.presentation.emotion.component.atom.SpeechBubbleText +import com.threegap.bitnagil.presentation.emotion.model.EmotionUiModel + +@Composable +fun EmotionLoadingView( + emotion: EmotionUiModel, +) { + Box( + modifier = Modifier.fillMaxSize().background(BitnagilTheme.colors.white), + ) { + BackgroundBox(modifier = Modifier.fillMaxSize()) + + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpeechBubbleText( + text = getRecommendRoutineText(emotion), + backgroundColor = 0xFF000000, + textColor = 0xFFFFFFFF, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + .width(200.dp) + .height(280.dp) + .offset(y = 16.dp) + .zIndex(1f), + ) { + Image( + painter = painterResource(R.drawable.img_marble_pomo), + contentDescription = null, + ) + + Image( + modifier = Modifier + .offset(y = 117.dp, x = 18.dp) + .zIndex(2f), + painter = painterResource(R.drawable.img_marble_pomo_left_hand), + contentDescription = null, + ) + + Image( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(y = 117.dp, x = (-17).dp) + .zIndex(2f), + painter = painterResource(R.drawable.img_marble_pomo_right_hand), + contentDescription = null, + ) + + EmotionMarbleImage( + modifier = Modifier + .size(120.dp) + .align(Alignment.TopCenter) + .offset(y = 97.dp) + .zIndex(1f), + image = emotion.image, + ) + } + + Image( + modifier = Modifier.height(150.dp).fillMaxWidth(), + painter = painterResource(R.drawable.img_ground), + contentDescription = null, + contentScale = ContentScale.FillBounds, + ) + } + } +} + +@Composable +private fun BackgroundBox(modifier: Modifier = Modifier) { + BoxWithConstraints(modifier = modifier) { + val height = constraints.maxHeight + val width = constraints.maxWidth + + BackgroundIcon( + iconResourceId = R.drawable.ic_wakeup, + offsetX = (width * 0.03f).pxToDp(), + offsetY = (height * 0.03f).pxToDp(), + ) + + BackgroundIcon( + iconResourceId = R.drawable.ic_rest, + offsetX = (width * 0.42f).pxToDp(), + offsetY = (height * 0.08f).pxToDp(), + ) + + BackgroundIcon( + iconResourceId = R.drawable.ic_shine, + offsetX = (width * 0.87f).pxToDp(), + offsetY = (height * 0.05f).pxToDp(), + ) + + BackgroundIcon( + iconResourceId = R.drawable.ic_grow, + offsetX = (width * -0.05f).pxToDp(), + offsetY = (height * 0.27f).pxToDp(), + ) + + BackgroundIcon( + iconResourceId = R.drawable.img_start_time, + offsetX = (width * 0.66f).pxToDp(), + offsetY = (height * 0.25f).pxToDp(), + ) + + BackgroundIcon( + iconResourceId = R.drawable.ic_outside, + offsetX = (width * -0.14f).pxToDp(), + offsetY = (height * 0.63f).pxToDp(), + ) + + BackgroundIcon( + iconResourceId = R.drawable.ic_connect, + offsetX = (width * 0.20f).pxToDp(), + offsetY = (height * 0.46f).pxToDp(), + ) + + BackgroundIcon( + iconResourceId = R.drawable.img_routine_period, + offsetX = (width * 0.93f).pxToDp(), + offsetY = (height * 0.54f).pxToDp(), + ) + } +} + +@Composable +private fun BackgroundIcon(iconResourceId: Int, offsetX: Dp, offsetY: Dp) { + Image( + modifier = Modifier.size(72.dp).offset(x = offsetX, y = offsetY), + painter = painterResource(iconResourceId), + colorFilter = ColorFilter.tint(BitnagilTheme.colors.coolGray98), + contentDescription = null, + ) +} + +private fun getRecommendRoutineText(emotion: EmotionUiModel): String { + return when (emotion.emotionType) { + "CALM" -> "평온한 하루에 맞춰\n루틴을 추천해드릴게요!" + "VITALITY" -> "활기찬 하루에 맞춰\n루틴을 추천해드릴게요!" + "LETHARGY" -> "무기력한 하루에 맞춰\n루틴을 추천해드릴게요!" + "ANXIETY" -> "불안한 하루에 맞춰\n루틴을 추천해드릴게요!" + "SATISFACTION" -> "만족스러운 하루에 맞춰\n루틴을 추천해드릴게요!" + "FATIGUE" -> "피곤한 하루에 맞춰\n루틴을 추천해드릴게요!" + else -> "감정에 맞춰\n루틴을 추천해드릴게요!" + } +} + +@Preview +@Composable +private fun EmotionLoadingViewPreview() { + BitnagilTheme { + EmotionLoadingView( + emotion = EmotionUiModel.Default, + ) + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/EmotionRecommendRoutineScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/EmotionRecommendRoutineScreen.kt new file mode 100644 index 00000000..538383e3 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/EmotionRecommendRoutineScreen.kt @@ -0,0 +1,138 @@ +package com.threegap.bitnagil.presentation.emotion.component.template + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.component.atom.BitnagilSelectButton +import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButton +import com.threegap.bitnagil.designsystem.component.atom.BitnagilTextButtonColor +import com.threegap.bitnagil.presentation.emotion.model.EmotionImageUiModel +import com.threegap.bitnagil.presentation.emotion.model.EmotionRecommendRoutineUiModel +import com.threegap.bitnagil.presentation.emotion.model.EmotionScreenStep +import com.threegap.bitnagil.presentation.emotion.model.EmotionUiModel +import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionState + +@Composable +fun EmotionRecommendRoutineScreen( + state: EmotionState, + onClickRoutine: (String) -> Unit, + onClickRegisterRecommendRoutines: () -> Unit, + onClickSkip: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = BitnagilTheme.colors.coolGray99) + .statusBarsPadding() + .padding(start = 16.dp, end = 16.dp, bottom = 20.dp, top = 32.dp), + ) { + Spacer(modifier = Modifier.height(54.dp)) + + Text( + text = "오늘의 감정에 맞는 루틴을 준비했어요!", + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.title2Bold, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "오늘의 감정에 맞춰 준비된 맞춤 루틴이에요.\n원하는 루틴을 골라 가볍게 시작해 보세요.", + color = BitnagilTheme.colors.coolGray50, + style = BitnagilTheme.typography.body1Medium, + ) + + Spacer(modifier = Modifier.height(28.dp)) + + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(state = scrollState), + ) { + for (recommendRoutine in state.recommendRoutines) { + BitnagilSelectButton( + title = recommendRoutine.name, + description = recommendRoutine.description, + onClick = { onClickRoutine(recommendRoutine.id) }, + selected = recommendRoutine.selected, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + BitnagilTextButton( + text = "변경하기", + onClick = onClickRegisterRecommendRoutines, + enabled = state.registerRecommendRoutinesButtonEnabled, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(10.dp)) + + BitnagilTextButton( + text = "건너뛰기", + onClick = onClickSkip, + colors = BitnagilTextButtonColor.skip().copy( + defaultBackgroundColor = Color.Transparent, + pressedBackgroundColor = Color.Transparent, + disabledBackgroundColor = Color.Transparent, + ), + textStyle = BitnagilTheme.typography.body2Regular, + textDecoration = TextDecoration.Underline, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview +@Composable +private fun Preview() { + BitnagilTheme { + EmotionRecommendRoutineScreen( + state = EmotionState( + emotionTypeUiModels = listOf( + EmotionUiModel( + emotionType = "emotionType", + emotionMarbleName = "emotionMarbleName", + image = EmotionImageUiModel.Url( + url = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", + offlineBackupImageResourceId = null, + ), + ), + ), + isLoading = false, + step = EmotionScreenStep.RecommendRoutines, + recommendRoutines = listOf( + EmotionRecommendRoutineUiModel( + id = "1", + name = "루틴 이름", + description = "루틴 설명", + selected = true, + ), + ), + showLoadingView = false, + ), + onClickRoutine = {}, + onClickRegisterRecommendRoutines = {}, + onClickSkip = {}, + ) + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/SimpleEmotionSelectionScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/SimpleEmotionSelectionScreen.kt new file mode 100644 index 00000000..95e14dfd --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/SimpleEmotionSelectionScreen.kt @@ -0,0 +1,130 @@ +package com.threegap.bitnagil.presentation.emotion.component.template + +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.component.block.BitnagilTopBar +import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple +import com.threegap.bitnagil.presentation.emotion.component.atom.EmotionMarbleImage +import com.threegap.bitnagil.presentation.emotion.model.EmotionImageUiModel +import com.threegap.bitnagil.presentation.emotion.model.EmotionScreenStep +import com.threegap.bitnagil.presentation.emotion.model.EmotionUiModel +import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionState + +@Composable +fun SimpleEmotionSelectionScreen( + state: EmotionState, + onClickPreviousButton: () -> Unit, + onClickEmotion: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = BitnagilTheme.colors.white) + .statusBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BitnagilTopBar( + showBackButton = true, + title = "오늘 감정 등록하기", + onBackClick = onClickPreviousButton, + ) + + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), + ) { + items(state.emotionTypeUiModels) { emotion -> + Column( + modifier = Modifier + .clickableWithoutRipple { onClickEmotion(emotion.emotionType) }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + EmotionMarbleImage( + modifier = Modifier.aspectRatio(1f), + image = emotion.image, + ) + + Text( + text = emotion.emotionMarbleName, + style = BitnagilTheme.typography.body1Regular.copy(color = BitnagilTheme.colors.coolGray20), + ) + } + } + } + } + } +} + +@Preview(heightDp = 360, widthDp = 360) +@Composable +private fun Preview() { + BitnagilTheme { + SimpleEmotionSelectionScreen( + state = EmotionState( + emotionTypeUiModels = listOf( + EmotionUiModel( + emotionType = "emotionType", + emotionMarbleName = "name1", + image = EmotionImageUiModel.Url( + url = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", + offlineBackupImageResourceId = null, + ), + ), + EmotionUiModel( + emotionType = "emotionType", + emotionMarbleName = "name2", + image = EmotionImageUiModel.Url( + url = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", + offlineBackupImageResourceId = null, + ), + ), + EmotionUiModel( + emotionType = "emotionType", + emotionMarbleName = "name3", + image = EmotionImageUiModel.Url( + url = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", + offlineBackupImageResourceId = null, + ), + ), + EmotionUiModel( + emotionType = "emotionType", + emotionMarbleName = "name4", + image = EmotionImageUiModel.Url( + url = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", + offlineBackupImageResourceId = null, + ), + ), + ), + isLoading = false, + step = EmotionScreenStep.Emotion, + recommendRoutines = listOf(), + showLoadingView = false, + ), + onClickEmotion = { _ -> }, + onClickPreviousButton = {}, + ) + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/SwipeEmotionSelectionScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/SwipeEmotionSelectionScreen.kt new file mode 100644 index 00000000..7398231b --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/component/template/SwipeEmotionSelectionScreen.kt @@ -0,0 +1,462 @@ +package com.threegap.bitnagil.presentation.emotion.component.template + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +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.PaddingValues +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.R +import com.threegap.bitnagil.designsystem.component.block.BitnagilTopBar +import com.threegap.bitnagil.presentation.common.dimension.dpToPx +import com.threegap.bitnagil.presentation.emotion.component.atom.EmotionMarbleImage +import com.threegap.bitnagil.presentation.emotion.model.EmotionImageUiModel +import com.threegap.bitnagil.presentation.emotion.model.EmotionScreenStep +import com.threegap.bitnagil.presentation.emotion.model.EmotionUiModel +import com.threegap.bitnagil.presentation.emotion.model.mvi.EmotionState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +@Composable +fun SwipeEmotionSelectionScreen( + state: EmotionState, + onClickPreviousButton: () -> Unit, + onSelectEmotion: (String) -> Unit, +) { + val fadeInTransition = remember { fadeIn(animationSpec = tween(150)) } + val fadeOutTransition = remember { fadeOut(animationSpec = tween(50)) } + + val emotions = remember(state.emotionTypeUiModels) { state.emotionTypeUiModels + EmotionUiModel.Default } + val actualItemCount = emotions.size + + val pagerState = rememberPagerState( + initialPage = Int.MAX_VALUE / 2 - (Int.MAX_VALUE / 2 % actualItemCount), + pageCount = { Int.MAX_VALUE }, + ) + var showText by remember { mutableStateOf(true) } + val currentItem = emotions[pagerState.currentPage % actualItemCount] + + LaunchedEffect(pagerState.isScrollInProgress) { + if (pagerState.isScrollInProgress) { + showText = false + } else { + delay(250) + showText = true + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = BitnagilTheme.colors.white) + .statusBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BitnagilTopBar( + showBackButton = true, + title = "오늘 감정 등록하기", + onBackClick = onClickPreviousButton, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + state.emotionTypeUiModels.forEach { emotion -> + EmotionMarbleImage( + modifier = Modifier.size(40.dp), + image = emotion.image, + alpha = if (emotion.emotionType == currentItem.emotionType) 1f else 0.3f, + ) + } + } + + Spacer(modifier = Modifier.height(26.dp)) + + EmotionDescriptionText( + emotion = currentItem, + showText = showText, + enterTransition = fadeInTransition, + exitTransition = fadeOutTransition, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier.weight(1f), + ) { + EmotionPager( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 90.dp) + .zIndex(1f), + emotions = emotions, + enabled = !state.isLoading, + onSelectEmotion = onSelectEmotion, + pagerState = pagerState, + showText = showText, + marbleNameTextEnterTransition = fadeInTransition, + marbleNameTextExitTransition = fadeOutTransition, + ) + + Column( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GestureDescriptionText( + currentEmotionSelectable = currentItem.selectable, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Image( + painter = painterResource(R.drawable.img_pomo_hand), + contentScale = ContentScale.FillBounds, + contentDescription = null, + modifier = Modifier + .width(263.dp) + .height(192.dp), + ) + } + + Image( + painter = painterResource(R.drawable.img_pomo_thumb), + modifier = Modifier + .align(Alignment.BottomCenter) + .zIndex(3f) + .offset(x = (-20).dp, y = (-70).dp), + contentDescription = null, + ) + } + } + + AnimatedVisibility( + visible = state.showLoadingView, + enter = fadeIn(), + exit = ExitTransition.None, + ) { + EmotionLoadingView(emotion = currentItem) + } + } +} + +@Composable +private fun EmotionDescriptionText( + emotion: EmotionUiModel, + showText: Boolean, + enterTransition: EnterTransition, + exitTransition: ExitTransition, +) { + Box( + modifier = Modifier.height(102.dp), + ) { + AnimatedVisibility( + visible = emotion.message != null && showText, + enter = enterTransition, + exit = exitTransition, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .background(Color(emotion.symbolBackgroundColor), shape = RoundedCornerShape(12.dp)) + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = emotion.message ?: "", + style = BitnagilTheme.typography.cafe24SsurroundAir2.copy(color = Color(emotion.symbolColor)), + textAlign = TextAlign.Center, + maxLines = 2, + minLines = 2, + ) + } + + Canvas( + modifier = Modifier + .height(10.dp) + .width(24.dp), + ) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width / 2, size.height) + lineTo(0f, 0f) + close() + } + drawPath(path, color = Color(emotion.symbolBackgroundColor)) + } + } + } + } +} + +@Composable +private fun GestureDescriptionText( + currentEmotionSelectable: Boolean, +) { + if (currentEmotionSelectable) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("선택한 감정 구슬을 아래로 놓아주세요", style = BitnagilTheme.typography.body2Medium.copy(color = BitnagilTheme.colors.coolGray50)) + + Spacer(modifier = Modifier.height(12.dp)) + + Image( + painter = painterResource(R.drawable.ic_double_down_arrow_24), + modifier = Modifier.size(24.dp), + contentDescription = null, + ) + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(26.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_double_left_arrow_24), + modifier = Modifier.size(24.dp), + contentDescription = null, + ) + + Text( + "좌우로 스와이프해\n감정 구슬을 골라주세요", + style = BitnagilTheme.typography.body2Medium.copy(color = BitnagilTheme.colors.coolGray50), + textAlign = TextAlign.Center, + ) + + Image( + painter = painterResource(R.drawable.ic_double_right_arrow_24), + modifier = Modifier.size(24.dp), + contentDescription = null, + ) + } + } +} + +@Composable +private fun EmotionPager( + modifier: Modifier = Modifier, + emotions: List, + enabled: Boolean, + onSelectEmotion: (String) -> Unit, + pagerState: PagerState, + showText: Boolean, + marbleNameTextEnterTransition: EnterTransition, + marbleNameTextExitTransition: ExitTransition, +) { + val currentItem = emotions[pagerState.currentPage % emotions.size] + + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.TopCenter, + ) { + val density = LocalDensity.current + val screenWidth = with(density) { constraints.maxWidth.toDp() } + + val itemSize = 140.dp + val centerItemYOffset = 50.dp + val contentPadding = (screenWidth - itemSize) / 2 + val pageSpacing = ((screenWidth - itemSize * 2) / 2) + + HorizontalPager( + modifier = Modifier.fillMaxHeight(), + state = pagerState, + pageSpacing = pageSpacing, + contentPadding = PaddingValues(horizontal = contentPadding), + verticalAlignment = Alignment.Top, + userScrollEnabled = enabled, + ) { page -> + val itemIndex = page % emotions.size + EmotionPagerItem( + emotion = emotions[itemIndex], + pagerState = pagerState, + page = page, + size = itemSize, + centerItemYOffset = centerItemYOffset.dpToPx(), + maximumDraggableYOffset = constraints.maxHeight - (itemSize + centerItemYOffset).dpToPx(), + enabled = enabled && emotions[itemIndex].selectable, + onSelectEmotion = onSelectEmotion, + ) + } + + AnimatedVisibility( + visible = showText, + enter = marbleNameTextEnterTransition, + exit = marbleNameTextExitTransition, + ) { + Box( + modifier = Modifier + .background(Color(currentItem.symbolBackgroundColor), shape = RoundedCornerShape(10.dp)) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = currentItem.emotionMarbleName, + style = BitnagilTheme.typography.title3SemiBold, + color = Color(currentItem.symbolColor), + ) + } + } + } +} + +@Composable +private fun EmotionPagerItem( + emotion: EmotionUiModel, + pagerState: PagerState, + page: Int, + size: Dp, + centerItemYOffset: Float, + maximumDraggableYOffset: Float = Float.MAX_VALUE, + enabled: Boolean, + onSelectEmotion: (String) -> Unit, +) { + val pageOffset = ( + (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + ).absoluteValue + + val offsetY = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + + EmotionMarbleImage( + image = emotion.image, + modifier = Modifier + .size(size) + .aspectRatio(1f) + .graphicsLayer { + translationY = lerp(start = centerItemYOffset * 1f, stop = 0f, pageOffset) + } + .offset { + IntOffset(0, offsetY.value.toInt()) + } + .draggable( + orientation = Orientation.Vertical, + enabled = (pageOffset == 0f && enabled), + state = rememberDraggableState { deltaY -> + coroutineScope.launch { + val newOffsetY = offsetY.value + deltaY + if (newOffsetY in 0f..maximumDraggableYOffset) { + offsetY.snapTo(newOffsetY) + } + } + }, + onDragStopped = { + coroutineScope.launch { + val targetOffsetY = if (offsetY.value >= maximumDraggableYOffset * 0.8) { + onSelectEmotion(emotion.emotionType) + maximumDraggableYOffset + } else { + 0f + } + + offsetY.animateTo( + targetValue = targetOffsetY, + animationSpec = tween(durationMillis = 300), + ) + } + }, + ), + ) +} + +@Preview(widthDp = 360, heightDp = 800) +@Composable +private fun Preview() { + BitnagilTheme { + SwipeEmotionSelectionScreen( + state = EmotionState( + emotionTypeUiModels = listOf( + EmotionUiModel( + emotionType = "emotionType", + emotionMarbleName = "구슬 이름 1", + image = EmotionImageUiModel.Url( + url = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", + offlineBackupImageResourceId = null, + ), + symbolBackgroundColor = 0xFFEFECFF, + symbolColor = 0xFF692BD0, + ), + EmotionUiModel( + emotionType = "emotionType", + emotionMarbleName = "구슬 이름 2", + image = EmotionImageUiModel.Url( + url = "https://bitnagil-s3.s3.ap-northeast-2.amazonaws.com/home_satisfaction.png", + offlineBackupImageResourceId = null, + ), + symbolBackgroundColor = 0xFFE9FAD0, + symbolColor = 0xFF609F00, + ), + ), + isLoading = false, + step = EmotionScreenStep.Emotion, + recommendRoutines = listOf(), + showLoadingView = false, + ), + onClickPreviousButton = {}, + onSelectEmotion = {}, + ) + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/EmotionImageUiModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/EmotionImageUiModel.kt new file mode 100644 index 00000000..44d92fd9 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/EmotionImageUiModel.kt @@ -0,0 +1,10 @@ +package com.threegap.bitnagil.presentation.emotion.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class EmotionImageUiModel : Parcelable { + data class Url(val url: String, val offlineBackupImageResourceId: Int?) : EmotionImageUiModel() + data class Resource(val resourceId: Int) : EmotionImageUiModel() +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/EmotionUiModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/EmotionUiModel.kt index 4f575688..487e7275 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/EmotionUiModel.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/EmotionUiModel.kt @@ -9,15 +9,23 @@ import kotlinx.parcelize.Parcelize data class EmotionUiModel( val emotionType: String, val emotionMarbleName: String, - val imageUrl: String, - val offlineBackupImageResourceId: Int?, + val image: EmotionImageUiModel, + val selectable: Boolean = true, + val message: String? = null, + val symbolBackgroundColor: Long = 0xFFEAEBEC, + val symbolColor: Long = 0xFF878A93, ) : Parcelable { companion object { fun fromDomain(emotion: Emotion) = EmotionUiModel( emotionType = emotion.emotionType, emotionMarbleName = emotion.emotionMarbleName, - imageUrl = emotion.imageUrl, - offlineBackupImageResourceId = getOfflineBackupImageResourceId(emotion.emotionType), + image = EmotionImageUiModel.Url( + url = emotion.imageUrl, + offlineBackupImageResourceId = getOfflineBackupImageResourceId(emotion.emotionType), + ), + message = getMessage(emotion.emotionType), + symbolBackgroundColor = getSymbolBackgroundColor(emotion.emotionType), + symbolColor = getSymbolColor(emotion.emotionType), ) private fun getOfflineBackupImageResourceId(emotionType: String): Int? { @@ -31,5 +39,51 @@ data class EmotionUiModel( else -> null } } + + private fun getMessage(emotionType: String): String? { + return when (emotionType) { + "CALM" -> "평온함은 마음이 고요하고 편안해\n균형을 이루는 상태에요." + "VITALITY" -> "활기참은 생기가 가득 차\n활발하고 적극적인 상태예요." + "LETHARGY" -> "무기력함은 의욕이 없어 아무것도\n하기 힘든 상태예요." + "ANXIETY" -> "불안함은 마음이 불안정하고 쉽게\n안심하기 어려운 상태예요." + "SATISFACTION" -> "만족함은 기대가 충족되어\n더 바랄 것이 없는 상태예요." + "FATIGUE" -> "피곤함은 몸과 마음이 지쳐\n휴식이 필요한 상태예요." + else -> null + } + } + + private fun getSymbolBackgroundColor(emotionType: String): Long { + return when (emotionType) { + "CALM" -> 0xFFEFECFF + "VITALITY" -> 0xFFE9FAD0 + "LETHARGY" -> 0xFFEAEBEC + "ANXIETY" -> 0xFFFFEEE4 + "SATISFACTION" -> 0xFFE2F3F6 + "FATIGUE" -> 0xFFFFE1E1 + else -> 0xFFEAEBEC + } + } + + private fun getSymbolColor(emotionType: String): Long { + return when (emotionType) { + "CALM" -> 0xFF692BD0 + "VITALITY" -> 0xFF609F00 + "LETHARGY" -> 0xFF5A5C63 + "ANXIETY" -> 0xFFFE7120 + "SATISFACTION" -> 0xFF26A792 + "FATIGUE" -> 0xFFFF5151 + else -> 0xFF878A93 + } + } + + val Default = EmotionUiModel( + emotionType = "NONE", + emotionMarbleName = "구슬 선택", + image = EmotionImageUiModel.Resource(R.drawable.default_marble), + selectable = false, + message = "오늘 기분 어때요?\n기록해 두면 내 루틴에 도움 돼요!", + symbolBackgroundColor = 0xFFEAEBEC, + symbolColor = 0xFF171719, + ) } } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionIntent.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionIntent.kt index 49b62216..9e155caa 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionIntent.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionIntent.kt @@ -8,6 +8,7 @@ sealed class EmotionIntent : MviIntent { data class EmotionListLoadSuccess(val emotionTypeUiModels: List) : EmotionIntent() data class RegisterEmotionSuccess(val recommendRoutines: List) : EmotionIntent() data object RegisterEmotionLoading : EmotionIntent() + data class RegisterEmotionFailure(val message: String) : EmotionIntent() data object RegisterRecommendRoutinesLoading : EmotionIntent() data object RegisterRecommendRoutinesSuccess : EmotionIntent() data object RegisterRecommendRoutinesFailure : EmotionIntent() diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionSideEffect.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionSideEffect.kt index 819135c1..b039ec89 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionSideEffect.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionSideEffect.kt @@ -4,4 +4,5 @@ import com.threegap.bitnagil.presentation.common.mviviewmodel.MviSideEffect sealed class EmotionSideEffect : MviSideEffect { data object NavigateToBack : EmotionSideEffect() + data class ShowToast(val message: String) : EmotionSideEffect() } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionState.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionState.kt index 3e016ddb..b095935f 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionState.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/emotion/model/mvi/EmotionState.kt @@ -14,6 +14,7 @@ data class EmotionState( val isLoading: Boolean, val recommendRoutines: List, val step: EmotionScreenStep, + val showLoadingView: Boolean, ) : MviState { companion object { val Init = EmotionState( @@ -21,6 +22,7 @@ data class EmotionState( isLoading = true, recommendRoutines = emptyList(), step = EmotionScreenStep.Emotion, + showLoadingView = false, ) }