diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 241cf0d3..eea5c678 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(projects.feature.login) implementation(projects.feature.main) implementation(projects.feature.taskCertification) + implementation(projects.feature.goalEditor) // Firebase implementation(platform(libs.google.firebase.bom)) diff --git a/app/src/main/java/com/yapp/twix/di/FeatureModules.kt b/app/src/main/java/com/yapp/twix/di/FeatureModules.kt index c820c141..3286e700 100644 --- a/app/src/main/java/com/yapp/twix/di/FeatureModules.kt +++ b/app/src/main/java/com/yapp/twix/di/FeatureModules.kt @@ -1,5 +1,6 @@ package com.yapp.twix.di +import com.twix.goal_editor.di.goalEditorModule import com.twix.home.di.homeModule import com.twix.login.di.loginModule import com.twix.main.di.mainModule @@ -12,4 +13,5 @@ val featureModules: List = mainModule, homeModule, taskCertificationModule, + goalEditorModule, ) diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/bottomsheet/CommonBottomSheet.kt b/core/design-system/src/main/java/com/twix/designsystem/components/bottomsheet/CommonBottomSheet.kt index 1c185742..cdc380a5 100644 --- a/core/design-system/src/main/java/com/twix/designsystem/components/bottomsheet/CommonBottomSheet.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/bottomsheet/CommonBottomSheet.kt @@ -77,7 +77,7 @@ fun CommonBottomSheet( if (!rendering) return - BackHandler { onDismissRequest() } + BackHandler(enabled = internalVisible) { onDismissRequest() } BoxWithConstraints( modifier = diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/common/CommonSwitch.kt b/core/design-system/src/main/java/com/twix/designsystem/components/common/CommonSwitch.kt new file mode 100644 index 00000000..ac156819 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/common/CommonSwitch.kt @@ -0,0 +1,61 @@ +package com.twix.designsystem.components.common + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import kotlin.math.roundToInt + +@Composable +fun CommonSwitch( + modifier: Modifier = Modifier, + checked: Boolean, + onClick: (Boolean) -> Unit, +) { + val density = LocalDensity.current + val minBound = with(density) { 0.dp.toPx() } + val maxBound = with(density) { 18.dp.toPx() } + val state by animateFloatAsState( + targetValue = if (checked) maxBound else minBound, + animationSpec = tween(durationMillis = 500), + label = "common switch", + ) + + Box( + modifier = + modifier + .size(width = 48.dp, height = 30.dp) + .clip(RoundedCornerShape(999.dp)) + .background(if (checked) GrayColor.C500 else CommonColor.White) + .border(1.dp, GrayColor.C500, RoundedCornerShape(999.dp)) + .clickable(onClick = { onClick(!checked) }), + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = + Modifier + .offset { IntOffset(state.roundToInt(), 0) } + .padding(4.dp) + .size(22.dp) + .clip(CircleShape) + .background(if (checked) CommonColor.White else GrayColor.C500), + ) + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/dialog/CommonDialog.kt b/core/design-system/src/main/java/com/twix/designsystem/components/dialog/CommonDialog.kt new file mode 100644 index 00000000..82bcee08 --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/dialog/CommonDialog.kt @@ -0,0 +1,187 @@ +package com.twix.designsystem.components.dialog + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +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.twix.designsystem.components.button.AppButton +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.DimmedColor +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle +import com.twix.ui.extension.noRippleClickable + +@Composable +fun CommonDialog( + modifier: Modifier = Modifier, + visible: Boolean, + confirmText: String, + dismissText: String? = null, + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit, + onConfirm: () -> Unit, + onDismiss: (() -> Unit)? = null, +) { + BackHandler(enabled = visible) { onDismissRequest() } + + Box( + modifier = + Modifier + .fillMaxSize() + .then(modifier), + contentAlignment = Alignment.Center, + ) { + DialogScrim(visible = visible, onDismissRequest = onDismissRequest) + + DialogContent( + modifier = + Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(), + visible = visible, + confirmText = confirmText, + dismissText = dismissText, + content = content, + onConfirm = onConfirm, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun DialogScrim( + visible: Boolean, + onDismissRequest: () -> Unit, +) { + val fadeDuration = 160 + + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(fadeDuration)), + exit = fadeOut(animationSpec = tween(fadeDuration)), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(DimmedColor.D070) + .noRippleClickable(onClick = onDismissRequest), + ) + } +} + +@Composable +private fun DialogContent( + modifier: Modifier, + visible: Boolean, + confirmText: String, + dismissText: String? = null, + content: @Composable ColumnScope.() -> Unit, + onConfirm: () -> Unit, + onDismiss: (() -> Unit)? = null, +) { + val fadeDuration = 160 + + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(fadeDuration)), + exit = fadeOut(animationSpec = tween(fadeDuration)), + ) { + Surface( + shape = RoundedCornerShape(20.dp), + color = CommonColor.White, + modifier = modifier, + ) { + Column( + modifier = + Modifier + .padding(horizontal = 20.dp) + .padding(top = 24.dp, bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + if (dismissText != null && onDismiss != null) { + AppButton( + modifier = Modifier.weight(1f), + text = dismissText, + textColor = GrayColor.C500, + backgroundColor = CommonColor.White, + border = BorderStroke(1.dp, GrayColor.C500), + onClick = onDismiss, + ) + + Spacer(Modifier.width(8.dp)) + } + + AppButton( + modifier = Modifier.weight(1f), + text = confirmText, + onClick = onConfirm, + ) + } + } + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun Preview() { + TwixTheme { + CommonDialog( + visible = true, + confirmText = "확인", + dismissText = "취소", + onDismissRequest = {}, + onConfirm = {}, + onDismiss = {}, + content = { + AppText( + text = "타이틀 텍스트", + style = AppTextStyle.T1, + color = GrayColor.C500, + ) + + Spacer(Modifier.height(8.dp)) + + AppText( + text = "내용 텍스트", + style = AppTextStyle.B2, + color = GrayColor.C400, + ) + }, + ) + } +} diff --git a/core/design-system/src/main/java/com/twix/designsystem/components/text_field/UnderlineTextField.kt b/core/design-system/src/main/java/com/twix/designsystem/components/text_field/UnderlineTextField.kt new file mode 100644 index 00000000..ec7e58cb --- /dev/null +++ b/core/design-system/src/main/java/com/twix/designsystem/components/text_field/UnderlineTextField.kt @@ -0,0 +1,106 @@ +package com.twix.designsystem.components.text_field + +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.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.LocalAppTypography +import com.twix.designsystem.theme.toTextStyle +import com.twix.domain.model.enums.AppTextStyle + +@Composable +fun UnderlineTextField( + modifier: Modifier = Modifier, + value: String, + placeHolder: String = "", + textStyle: AppTextStyle = AppTextStyle.T2, + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = true, + showTrailing: Boolean = false, + maxLines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + trailing: (@Composable () -> Unit)? = null, + onValueChange: (String) -> Unit, +) { + val typo = LocalAppTypography.current + + Column( + modifier = + Modifier + .height(52.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, + ) { + Spacer(Modifier.height(4.dp)) + + Box( + modifier = + modifier + .padding(horizontal = 8.dp, vertical = 10.dp), + contentAlignment = Alignment.CenterStart, + ) { + val shouldShowTrailing = trailing != null && showTrailing && value.isNotBlank() + + if (value.isBlank()) { + AppText( + text = placeHolder, + style = textStyle, + color = GrayColor.C200, + modifier = + Modifier + .align(Alignment.CenterStart), + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = value, + textStyle = textStyle.toTextStyle(typo).copy(color = GrayColor.C500), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + singleLine = singleLine, + maxLines = maxLines, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + + if (shouldShowTrailing) { + Box( + modifier = + Modifier + .padding(start = 10.dp), + contentAlignment = Alignment.Center, + ) { + trailing.invoke() + } + } + } + } + + Spacer(Modifier.height(4.dp)) + + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500, modifier = modifier) + } +} diff --git a/core/design-system/src/main/res/drawable/ic_arrow3_left.xml b/core/design-system/src/main/res/drawable/ic_arrow3_left.xml new file mode 100644 index 00000000..89be7347 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_arrow3_left.xml @@ -0,0 +1,13 @@ + + + diff --git a/core/design-system/src/main/res/drawable/ic_book.xml b/core/design-system/src/main/res/drawable/ic_book.xml new file mode 100644 index 00000000..a074b48c --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_book.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_chevron_down_circle.xml b/core/design-system/src/main/res/drawable/ic_chevron_down_circle.xml new file mode 100644 index 00000000..9a59f25e --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_chevron_down_circle.xml @@ -0,0 +1,16 @@ + + + + diff --git a/core/design-system/src/main/res/drawable/ic_clean.xml b/core/design-system/src/main/res/drawable/ic_clean.xml new file mode 100644 index 00000000..8b55c2f3 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_clean.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_default.xml b/core/design-system/src/main/res/drawable/ic_default.xml new file mode 100644 index 00000000..a3f2737c --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_default.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_emoji_add.xml b/core/design-system/src/main/res/drawable/ic_emoji_add.xml new file mode 100644 index 00000000..9b6007b1 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_emoji_add.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_empty_emoji.xml b/core/design-system/src/main/res/drawable/ic_empty_emoji.xml new file mode 100644 index 00000000..8fe6191c --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_empty_emoji.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_exercise.xml b/core/design-system/src/main/res/drawable/ic_exercise.xml new file mode 100644 index 00000000..d39953cb --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_exercise.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_health.xml b/core/design-system/src/main/res/drawable/ic_health.xml new file mode 100644 index 00000000..7442af1f --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_health.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_heart.xml b/core/design-system/src/main/res/drawable/ic_heart.xml new file mode 100644 index 00000000..250e3a5d --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/design-system/src/main/res/drawable/ic_laptop.xml b/core/design-system/src/main/res/drawable/ic_laptop.xml new file mode 100644 index 00000000..33daae37 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_laptop.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_minus.xml b/core/design-system/src/main/res/drawable/ic_minus.xml new file mode 100644 index 00000000..508d5507 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/design-system/src/main/res/drawable/ic_pencil.xml b/core/design-system/src/main/res/drawable/ic_pencil.xml new file mode 100644 index 00000000..b9df9fb6 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_pencil.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_plus.xml b/core/design-system/src/main/res/drawable/ic_plus.xml index 1785a79e..c9db1c8a 100644 --- a/core/design-system/src/main/res/drawable/ic_plus.xml +++ b/core/design-system/src/main/res/drawable/ic_plus.xml @@ -1,12 +1,12 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml index 8c344190..657bea88 100644 --- a/core/design-system/src/main/res/values/strings.xml +++ b/core/design-system/src/main/res/values/strings.xml @@ -6,6 +6,11 @@ 커플페이지 오늘 완료 + + + 매일 + 매주 + 매월 @@ -18,5 +23,19 @@ 오늘 우리 목표 첫 목표를 세워볼까요? + + + 목표 직접 만들기 + 목표 수정하기 + 목표를 입력해 보세요. + 반복 주기 + 시작 날짜 + 종료 날짜 + 종료 날짜 설정 + 아이콘 변경 + %s %s번 + + 종료 날짜가 시작 날짜보다 이전입니다. + \ No newline at end of file diff --git a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt index 803c1001..e657203a 100644 --- a/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt +++ b/core/navigation/src/main/java/com/twix/navigation/NavRoutes.kt @@ -30,4 +30,11 @@ sealed class NavRoutes( object TaskCertificationGraph : NavRoutes("task_certification_graph") object TaskCertificationRoute : NavRoutes("task_certification") + + /** + * GoalEditorGraph + * */ + object GoalEditorGraph : NavRoutes("goal_editor_graph") + + object GoalEditorRoute : NavRoutes("goal_editor") } diff --git a/core/ui/src/main/java/com/twix/ui/extension/DismissKeyboardOnTap.kt b/core/ui/src/main/java/com/twix/ui/extension/DismissKeyboardOnTap.kt new file mode 100644 index 00000000..5303b5ba --- /dev/null +++ b/core/ui/src/main/java/com/twix/ui/extension/DismissKeyboardOnTap.kt @@ -0,0 +1,29 @@ +package com.twix.ui.extension + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController + +@Composable +fun Modifier.dismissKeyboardOnTap( + enabled: Boolean = true, + onDismiss: () -> Unit = {}, +): Modifier { + if (!enabled) return this + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + return this.pointerInput(Unit) { + detectTapGestures( + onTap = { + focusManager.clearFocus() + keyboardController?.hide() + onDismiss() + }, + ) + } +} diff --git a/domain/src/main/java/com/twix/domain/model/enums/GoalIconType.kt b/domain/src/main/java/com/twix/domain/model/enums/GoalIconType.kt new file mode 100644 index 00000000..79abb906 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/enums/GoalIconType.kt @@ -0,0 +1,12 @@ +package com.twix.domain.model.enums + +enum class GoalIconType { + DEFAULT, + CLEAN, + EXERCISE, + BOOK, + PENCIL, + HEALTH, + HEART, + LAPTOP, +} diff --git a/domain/src/main/java/com/twix/domain/model/enums/RepeatType.kt b/domain/src/main/java/com/twix/domain/model/enums/RepeatType.kt new file mode 100644 index 00000000..61a44e25 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/enums/RepeatType.kt @@ -0,0 +1,7 @@ +package com.twix.domain.model.enums + +enum class RepeatType { + DAILY, + WEEKLY, + MONTHLY, +} diff --git a/feature/goal-editor/.gitignore b/feature/goal-editor/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/goal-editor/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/goal-editor/build.gradle.kts b/feature/goal-editor/build.gradle.kts new file mode 100644 index 00000000..c8916ac6 --- /dev/null +++ b/feature/goal-editor/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.twix.feature) +} + +android { + namespace = "com.twix.goal_editor" +} diff --git a/feature/goal-editor/consumer-rules.pro b/feature/goal-editor/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/goal-editor/proguard-rules.pro b/feature/goal-editor/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/goal-editor/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/goal-editor/src/main/AndroidManifest.xml b/feature/goal-editor/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/goal-editor/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorIntent.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorIntent.kt new file mode 100644 index 00000000..0ac5656b --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorIntent.kt @@ -0,0 +1,38 @@ +package com.twix.goal_editor + +import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.RepeatType +import com.twix.ui.base.Intent +import java.time.LocalDate + +sealed interface GoalEditorIntent : Intent { + data class SetIcon( + val icon: GoalIconType, + ) : GoalEditorIntent + + data class SetTitle( + val title: String, + ) : GoalEditorIntent + + data class SetRepeatType( + val repeatType: RepeatType, + ) : GoalEditorIntent + + data class SetRepeatCount( + val repeatCount: Int, + ) : GoalEditorIntent + + data class SetStartDate( + val startDate: LocalDate, + ) : GoalEditorIntent + + data class SetEndDate( + val endDate: LocalDate, + ) : GoalEditorIntent + + data class SetEndDateEnabled( + val enabled: Boolean, + ) : GoalEditorIntent + + data object Save : GoalEditorIntent +} diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorScreen.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorScreen.kt new file mode 100644 index 00000000..e3ec4af9 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorScreen.kt @@ -0,0 +1,436 @@ +package com.twix.goal_editor + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.twix.designsystem.R +import com.twix.designsystem.components.bottomsheet.CommonBottomSheet +import com.twix.designsystem.components.bottomsheet.model.CommonBottomSheetConfig +import com.twix.designsystem.components.button.AppButton +import com.twix.designsystem.components.calendar.Calendar +import com.twix.designsystem.components.dialog.CommonDialog +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.components.toast.ToastManager +import com.twix.designsystem.components.toast.model.ToastData +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import com.twix.designsystem.theme.TwixTheme +import com.twix.domain.model.enums.AppTextStyle +import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.RepeatType +import com.twix.goal_editor.component.EmojiPicker +import com.twix.goal_editor.component.GoalEditorTopBar +import com.twix.goal_editor.component.GoalInfoCard +import com.twix.goal_editor.component.GoalTextField +import com.twix.goal_editor.component.label +import com.twix.goal_editor.component.toRes +import com.twix.goal_editor.model.GoalEditorUiState +import com.twix.ui.extension.dismissKeyboardOnTap +import com.twix.ui.extension.noRippleClickable +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject +import java.time.LocalDate + +@Composable +fun GoalEditorRoute( + viewModel: GoalEditorViewModel = koinViewModel(), + toastManager: ToastManager = koinInject(), + isEdit: Boolean = false, + navigateToBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + /** + * LaunchedEffect 내부에서는 stringResource를 사용할 수 없고, context.getString을 그대로 사용하면 경고 발생 + * LaunchedEffect가 예전 context를 들고 있지 않도록 막아주는 용도 + * */ + val currentContext by rememberUpdatedState(context) + + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { effect -> + when (effect) { + is GoalEditorSideEffect.ShowToast -> toastManager.tryShow(ToastData(currentContext.getString(effect.resId), effect.type)) + } + } + } + + GoalEditorScreen( + uiState = uiState, + isEdit = isEdit, + onBack = navigateToBack, + onCommitTitle = { viewModel.dispatch(GoalEditorIntent.SetTitle(it)) }, + onSelectRepeatType = { viewModel.dispatch(GoalEditorIntent.SetRepeatType(it)) }, + onCommitIcon = { viewModel.dispatch(GoalEditorIntent.SetIcon(it)) }, + onCommitEndDate = { viewModel.dispatch(GoalEditorIntent.SetEndDate(it)) }, + onCommitStartDate = { viewModel.dispatch(GoalEditorIntent.SetStartDate(it)) }, + onCommitRepeatCount = { viewModel.dispatch(GoalEditorIntent.SetRepeatCount(it)) }, + onToggleEndDateEnabled = { viewModel.dispatch(GoalEditorIntent.SetEndDateEnabled(it)) }, + onComplete = { viewModel.dispatch(GoalEditorIntent.Save) }, + ) +} + +@Composable +fun GoalEditorScreen( + uiState: GoalEditorUiState, + isEdit: Boolean = false, + onBack: () -> Unit, + onCommitTitle: (String) -> Unit, + onSelectRepeatType: (RepeatType) -> Unit, + onCommitIcon: (GoalIconType) -> Unit, + onCommitEndDate: (LocalDate) -> Unit, + onCommitStartDate: (LocalDate) -> Unit, + onCommitRepeatCount: (Int) -> Unit, + onToggleEndDateEnabled: (Boolean) -> Unit, + onComplete: () -> Unit, +) { + var showRepeatCountBottomSheet by remember { mutableStateOf(false) } + var showCalendarBottomSheet by remember { mutableStateOf(false) } + var showIconEditorDialog by remember { mutableStateOf(false) } + var isEndDate by remember { mutableStateOf(true) } + var internalSelectedIcon by remember { mutableStateOf(uiState.selectedIcon) } + + Box { + Column( + modifier = + Modifier + .fillMaxSize() + .background(CommonColor.White) + .dismissKeyboardOnTap(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GoalEditorTopBar( + isEdit = isEdit, + onBack = onBack, + ) + + Spacer(Modifier.height(52.dp)) + + EmojiPicker( + icon = uiState.selectedIcon, + onClick = { showIconEditorDialog = true }, + ) + + Spacer(Modifier.height(44.dp)) + + GoalTextField( + value = uiState.goalTitle, + onCommitTitle = onCommitTitle, + ) + + Spacer(Modifier.height(44.dp)) + + GoalInfoCard( + selectedRepeatType = uiState.selectedRepeatType, + repeatCount = uiState.repeatCount, + startDate = uiState.startDate, + endDateEnabled = uiState.endDateEnabled, + endDate = uiState.endDate, + onSelectedRepeatType = onSelectRepeatType, + onShowRepeatCountBottomSheet = { showRepeatCountBottomSheet = true }, + onShowCalendarBottomSheet = { + isEndDate = it + showCalendarBottomSheet = true + }, + onToggleEndDateEnabled = onToggleEndDateEnabled, + ) + + Spacer(Modifier.weight(1f)) + + AppButton( + onClick = onComplete, + modifier = + Modifier + .padding(horizontal = 20.dp) + .padding(vertical = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.word_completion), + ) + } + + CommonBottomSheet( + visible = showCalendarBottomSheet, + config = CommonBottomSheetConfig(showHandle = false), + onDismissRequest = { showCalendarBottomSheet = false }, + content = { + Calendar( + initialDate = if (isEndDate) uiState.endDate else uiState.startDate, + onComplete = { + if (isEndDate) onCommitEndDate(it) else onCommitStartDate(it) + showCalendarBottomSheet = false + }, + ) + }, + ) + + CommonBottomSheet( + visible = showRepeatCountBottomSheet, + config = CommonBottomSheetConfig(showHandle = false), + onDismissRequest = { showRepeatCountBottomSheet = false }, + content = { + RepeatCountBottomSheetContent( + repeatCount = uiState.repeatCount, + selectedRepeatType = uiState.selectedRepeatType, + onCommit = { repeatType, repeatCount -> + onSelectRepeatType(repeatType) + onCommitRepeatCount(repeatCount) + showRepeatCountBottomSheet = false + }, + ) + }, + ) + + CommonDialog( + visible = showIconEditorDialog, + confirmText = stringResource(R.string.word_completion), + onDismissRequest = { showIconEditorDialog = false }, + onConfirm = { + onCommitIcon(internalSelectedIcon) + showIconEditorDialog = false + }, + content = { + IconEditorDialogContent( + selectedIcon = internalSelectedIcon, + onClick = { internalSelectedIcon = it }, + ) + }, + ) + } +} + +@Composable +private fun IconEditorDialogContent( + selectedIcon: GoalIconType, + onClick: (GoalIconType) -> Unit, +) { + AppText( + text = stringResource(R.string.icon_editor_dialog_title), + style = AppTextStyle.T1, + color = GrayColor.C500, + ) + + Spacer(Modifier.height(16.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(4), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(GoalIconType.entries) { + val isSelected = it == selectedIcon + + /** + * LazyVerticalGrid(GridCells.Fixed(4))는 각 아이템을 전체 폭을 4등분한 값으로 측정함 + * 따라서 아이템 루트에 CircleShape Border를 적용해도 LazyVerticalGrid가 측정한 아이템 폭에 의해 타원처럼 보일 수 있음 + * + * 그래서 바깥 Box가 LazyVerticalGrid가 측정한 폭을 받아 정렬만 담당하고, + * 실제 원형 영역은 안쪽 Box(size + CircleShape)로 적용함 + */ + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(CircleShape) + .border(1.dp, if (isSelected) GrayColor.C500 else GrayColor.C100, CircleShape) + .noRippleClickable(onClick = { onClick(it) }), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(it.toRes()), + contentDescription = "emoji", + modifier = + Modifier + .size(42.dp), + ) + } + } + } + } +} + +@Composable +private fun RepeatCountBottomSheetContent( + repeatCount: Int, + selectedRepeatType: RepeatType, + onCommit: (RepeatType, Int) -> Unit, +) { + var internalRepeatCount by remember { mutableIntStateOf(repeatCount) } + var internalSelectedRepeatType by remember { mutableStateOf(selectedRepeatType) } + val maxCount = if (internalSelectedRepeatType == RepeatType.WEEKLY) 6 else 25 + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AppText( + text = RepeatType.WEEKLY.label(), + style = AppTextStyle.B2, + color = if (internalSelectedRepeatType == RepeatType.WEEKLY) CommonColor.White else GrayColor.C500, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (internalSelectedRepeatType == RepeatType.WEEKLY) GrayColor.C500 else CommonColor.White) + .border(1.dp, GrayColor.C500, RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 5.5.dp) + .noRippleClickable(onClick = { + internalSelectedRepeatType = RepeatType.WEEKLY + internalRepeatCount = 0 + }), + ) + + AppText( + text = RepeatType.MONTHLY.label(), + style = AppTextStyle.B2, + color = if (internalSelectedRepeatType == RepeatType.MONTHLY) CommonColor.White else GrayColor.C500, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (internalSelectedRepeatType == RepeatType.MONTHLY) GrayColor.C500 else CommonColor.White) + .border(1.dp, GrayColor.C500, RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 5.5.dp) + .noRippleClickable(onClick = { + internalSelectedRepeatType = RepeatType.MONTHLY + internalRepeatCount = 0 + }), + ) + } + + Spacer(Modifier.height(36.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_minus), + contentDescription = "minus", + colorFilter = ColorFilter.tint(CommonColor.White), + modifier = + Modifier + .background(GrayColor.C500, CircleShape) + .padding(4.dp) + .size(28.dp) + .noRippleClickable(onClick = { if (internalRepeatCount > 1) internalRepeatCount-- }), + ) + + Row( + modifier = + Modifier + .width(96.dp) + .border(1.dp, GrayColor.C300, RoundedCornerShape(12.dp)) + .padding(horizontal = 27.5.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + AppText( + text = internalRepeatCount.toString(), + style = AppTextStyle.H2, + color = GrayColor.C500, + ) + + Spacer(Modifier.width(8.dp)) + + Box( + modifier = + Modifier + .size(24.dp), + contentAlignment = Alignment.Center, + ) { + AppText( + text = stringResource(R.string.word_count), + style = AppTextStyle.T2, + color = GrayColor.C300, + ) + } + } + + Image( + painter = painterResource(R.drawable.ic_plus), + contentDescription = "plus", + colorFilter = ColorFilter.tint(CommonColor.White), + modifier = + Modifier + .background(GrayColor.C500, CircleShape) + .padding(4.dp) + .size(28.dp) + .noRippleClickable(onClick = { if (maxCount > internalRepeatCount) internalRepeatCount++ }), + ) + } + + Spacer(Modifier.height(32.dp)) + + AppButton( + onClick = { onCommit(internalSelectedRepeatType, internalRepeatCount) }, + modifier = + Modifier + .padding(horizontal = 20.dp, vertical = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.word_completion), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TwixTheme { + val uiState = GoalEditorUiState() + + GoalEditorScreen( + uiState = uiState, + onBack = {}, + onCommitTitle = {}, + onSelectRepeatType = {}, + onCommitEndDate = {}, + onCommitStartDate = {}, + onCommitRepeatCount = {}, + onToggleEndDateEnabled = {}, + onComplete = {}, + onCommitIcon = {}, + isEdit = false, + ) + } +} diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorSideEffect.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorSideEffect.kt new file mode 100644 index 00000000..6e2fcd2a --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorSideEffect.kt @@ -0,0 +1,12 @@ +package com.twix.goal_editor + +import androidx.annotation.StringRes +import com.twix.designsystem.components.toast.model.ToastType +import com.twix.ui.base.SideEffect + +interface GoalEditorSideEffect : SideEffect { + data class ShowToast( + @param:StringRes val resId: Int, + val type: ToastType, + ) : GoalEditorSideEffect +} diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorViewModel.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorViewModel.kt new file mode 100644 index 00000000..c99766e8 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/GoalEditorViewModel.kt @@ -0,0 +1,72 @@ +package com.twix.goal_editor + +import androidx.lifecycle.viewModelScope +import com.twix.designsystem.R +import com.twix.designsystem.components.toast.model.ToastType +import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.RepeatType +import com.twix.goal_editor.model.GoalEditorUiState +import com.twix.ui.base.BaseViewModel +import kotlinx.coroutines.launch +import java.time.LocalDate + +class GoalEditorViewModel : + BaseViewModel( + GoalEditorUiState(), + ) { + override suspend fun handleIntent(intent: GoalEditorIntent) { + when (intent) { + GoalEditorIntent.Save -> save() + is GoalEditorIntent.SetIcon -> setIcon(intent.icon) + is GoalEditorIntent.SetEndDate -> setEndDate(intent.endDate) + is GoalEditorIntent.SetRepeatCount -> setRepeatCount(intent.repeatCount) + is GoalEditorIntent.SetRepeatType -> setRepeatType(intent.repeatType) + is GoalEditorIntent.SetStartDate -> setStartDate(intent.startDate) + is GoalEditorIntent.SetTitle -> setTitle(intent.title) + is GoalEditorIntent.SetEndDateEnabled -> setEndDateEnabled(intent.enabled) + } + } + + private fun setIcon(icon: GoalIconType) { + reduce { copy(selectedIcon = icon) } + } + + private fun setTitle(title: String) { + if (title.isBlank()) return + + reduce { copy(goalTitle = title) } + } + + private fun setRepeatType(repeatType: RepeatType) { + reduce { copy(selectedRepeatType = repeatType) } + } + + private fun setRepeatCount(repeatCount: Int) { + if (repeatCount <= 0) return + + reduce { copy(repeatCount = repeatCount) } + } + + private fun setStartDate(startDate: LocalDate) { + reduce { copy(startDate = startDate) } + } + + private fun setEndDate(endDate: LocalDate) { + reduce { copy(endDate = endDate) } + } + + private fun setEndDateEnabled(enabled: Boolean) { + reduce { copy(endDateEnabled = enabled) } + } + + private fun save() { + if (!currentState.isEnabled) return + + if (currentState.endDateEnabled && currentState.endDate.isBefore(currentState.startDate)) { + viewModelScope.launch { + emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_end_date_before_start_date, ToastType.ERROR)) + } + return + } + } +} diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/component/EmojiPicker.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/EmojiPicker.kt new file mode 100644 index 00000000..d3aa81c8 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/EmojiPicker.kt @@ -0,0 +1,55 @@ +package com.twix.goal_editor.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.theme.GrayColor +import com.twix.domain.model.enums.GoalIconType +import com.twix.ui.extension.noRippleClickable + +@Composable +fun EmojiPicker( + icon: GoalIconType, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .clip(CircleShape) + .background(GrayColor.C050) + .border(1.dp, GrayColor.C300, CircleShape) + .padding(26.dp) + .noRippleClickable(onClick = onClick), + ) { + Image( + painter = painterResource(icon.toRes()), + contentDescription = "emoji", + modifier = + Modifier + .size(56.dp), + ) + } +} + +@Composable +fun GoalIconType.toRes(): Int = + when (this) { + GoalIconType.DEFAULT -> R.drawable.ic_default + GoalIconType.CLEAN -> R.drawable.ic_clean + GoalIconType.EXERCISE -> R.drawable.ic_exercise + GoalIconType.BOOK -> R.drawable.ic_book + GoalIconType.PENCIL -> R.drawable.ic_pencil + GoalIconType.HEALTH -> R.drawable.ic_health + GoalIconType.HEART -> R.drawable.ic_heart + GoalIconType.LAPTOP -> R.drawable.ic_laptop + } diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalEditorTopBar.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalEditorTopBar.kt new file mode 100644 index 00000000..83cfe4b6 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalEditorTopBar.kt @@ -0,0 +1,87 @@ +package com.twix.goal_editor.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.GrayColor +import com.twix.domain.model.enums.AppTextStyle +import com.twix.ui.extension.noRippleClickable + +@Composable +fun GoalEditorTopBar( + isEdit: Boolean = false, + onBack: () -> Unit, +) { + val title = + if (isEdit) { + stringResource(R.string.goal_editor_title_edit) + } else { + stringResource(R.string.goal_editor_title) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) { + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + + Row( + modifier = + Modifier + .fillMaxWidth() + .height(60.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + + VerticalDivider(thickness = 1.dp, color = GrayColor.C500) + + AppText( + text = title, + style = AppTextStyle.H4Brand, + color = GrayColor.C500, + textAlign = TextAlign.Center, + modifier = + Modifier + .weight(1f), + ) + + VerticalDivider(thickness = 1.dp, color = GrayColor.C500) + + Box( + modifier = + Modifier + .padding(18.dp) + .size(24.dp), + ) + } + + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + } +} diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalInfoCard.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalInfoCard.kt new file mode 100644 index 00000000..c6d95d47 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalInfoCard.kt @@ -0,0 +1,253 @@ +package com.twix.goal_editor.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.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.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.common.CommonSwitch +import com.twix.designsystem.components.text.AppText +import com.twix.designsystem.theme.CommonColor +import com.twix.designsystem.theme.GrayColor +import com.twix.domain.model.enums.AppTextStyle +import com.twix.domain.model.enums.RepeatType +import com.twix.ui.extension.noRippleClickable +import java.time.LocalDate + +@Composable +fun GoalInfoCard( + selectedRepeatType: RepeatType, + repeatCount: Int, + startDate: LocalDate, + endDateEnabled: Boolean, + endDate: LocalDate, + onSelectedRepeatType: (RepeatType) -> Unit, + onShowRepeatCountBottomSheet: () -> Unit, + onShowCalendarBottomSheet: (Boolean) -> Unit, // true면 endDate + onToggleEndDateEnabled: (Boolean) -> Unit, +) { + Column( + modifier = + Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .border(1.dp, GrayColor.C500, RoundedCornerShape(12.dp)), + ) { + RepeatTypeSettings( + selectedRepeatType = selectedRepeatType, + repeatCount = repeatCount, + onSelectedRepeatType = onSelectedRepeatType, + onShowRepeatCountBottomSheet = onShowRepeatCountBottomSheet, + ) + + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + + DateSettings( + date = startDate, + onShowCalendarBottomSheet = { onShowCalendarBottomSheet(false) }, + ) + + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + + EndDateOption( + visible = endDateEnabled, + onToggle = onToggleEndDateEnabled, + ) + + AnimatedVisibility( + visible = endDateEnabled, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + Column { + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + + DateSettings( + date = endDate, + isEndDate = true, + onShowCalendarBottomSheet = { onShowCalendarBottomSheet(true) }, + ) + } + } + } +} + +@Composable +private fun RepeatTypeSettings( + selectedRepeatType: RepeatType, + repeatCount: Int, + onSelectedRepeatType: (RepeatType) -> Unit, + onShowRepeatCountBottomSheet: () -> Unit, +) { + val animationDuration = 160 + + Column( + modifier = + Modifier + .padding(16.dp), + ) { + HeaderText(stringResource(R.string.header_repeat_type)) + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + RepeatType.entries.forEachIndexed { index, type -> + val isSelected = selectedRepeatType == type + + AppText( + text = type.label(), + style = AppTextStyle.B2, + color = if (isSelected) CommonColor.White else GrayColor.C500, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (isSelected) GrayColor.C500 else CommonColor.White) + .border(1.dp, GrayColor.C500, RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 5.5.dp) + .noRippleClickable(onClick = { onSelectedRepeatType(type) }), + ) + + if (index != RepeatType.entries.lastIndex) Spacer(Modifier.width(8.dp)) + } + + Spacer(Modifier.weight(1f)) + + AnimatedVisibility( + visible = selectedRepeatType != RepeatType.DAILY, + enter = fadeIn(animationSpec = tween(durationMillis = animationDuration)), + exit = fadeOut(animationSpec = tween(durationMillis = animationDuration)), + ) { + Row( + modifier = + Modifier + .noRippleClickable(onClick = onShowRepeatCountBottomSheet), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = stringResource(R.string.repeat_count, selectedRepeatType.label(), repeatCount), + style = AppTextStyle.B2, + color = GrayColor.C500, + ) + + Image( + painter = painterResource(R.drawable.ic_chevron_down_circle), + contentDescription = "repeat type", + modifier = + Modifier + .size(24.dp), + ) + } + } + } + } +} + +@Composable +private fun DateSettings( + date: LocalDate, + isEndDate: Boolean = false, + onShowCalendarBottomSheet: () -> Unit, +) { + Row( + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + HeaderText(stringResource(if (isEndDate) R.string.header_end_date else R.string.header_start_date)) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = + Modifier + .noRippleClickable(onClick = onShowCalendarBottomSheet), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = "%s월 %s일".format(date.monthValue, date.dayOfMonth), + style = AppTextStyle.B2, + color = GrayColor.C500, + ) + + Image( + painter = painterResource(R.drawable.ic_chevron_down_circle), + contentDescription = "date", + modifier = + Modifier + .size(24.dp), + ) + } + } +} + +@Composable +private fun EndDateOption( + visible: Boolean = false, + onToggle: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HeaderText(stringResource(R.string.header_end_date_option)) + + Spacer(Modifier.weight(1f)) + + CommonSwitch( + checked = visible, + onClick = onToggle, + ) + } +} + +@Composable +private fun HeaderText(text: String) { + AppText( + text = text, + style = AppTextStyle.B1, + color = GrayColor.C500, + ) +} + +@Composable +fun RepeatType.label(): String = + when (this) { + RepeatType.DAILY -> stringResource(R.string.word_daily) + RepeatType.WEEKLY -> stringResource(R.string.word_weekly) + RepeatType.MONTHLY -> stringResource(R.string.word_monthly) + } diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalTextField.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalTextField.kt new file mode 100644 index 00000000..48669b98 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/component/GoalTextField.kt @@ -0,0 +1,49 @@ +package com.twix.goal_editor.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.twix.designsystem.R +import com.twix.designsystem.components.text_field.UnderlineTextField + +@Composable +fun GoalTextField( + value: String, + onCommitTitle: (String) -> Unit, +) { + val focusManager = LocalFocusManager.current + var internalValue by rememberSaveable(value) { mutableStateOf(value) } + // 초기에 무의미하게 commit 되는 것을 방지하는 상태 변수 + var wasFocused by remember { mutableStateOf(false) } + + UnderlineTextField( + modifier = + Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .onFocusChanged { state -> + if (wasFocused && !state.isFocused) { + onCommitTitle(internalValue.trim()) + } + wasFocused = state.isFocused + }, + value = internalValue, + placeHolder = stringResource(R.string.goal_editor_text_field_placeholder), + onValueChange = { internalValue = it }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + ) +} diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/di/GoalEditorModule.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/di/GoalEditorModule.kt new file mode 100644 index 00000000..f3c8dc17 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/di/GoalEditorModule.kt @@ -0,0 +1,15 @@ +package com.twix.goal_editor.di + +import com.twix.goal_editor.GoalEditorViewModel +import com.twix.goal_editor.navigation.GoalEditorNavGraph +import com.twix.navigation.NavRoutes +import com.twix.navigation.base.NavGraphContributor +import org.koin.core.module.dsl.viewModelOf +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val goalEditorModule = + module { + viewModelOf(::GoalEditorViewModel) + single(named(NavRoutes.GoalEditorGraph.route)) { GoalEditorNavGraph } + } diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/model/GoalEditorUiState.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/model/GoalEditorUiState.kt new file mode 100644 index 00000000..feee3572 --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/model/GoalEditorUiState.kt @@ -0,0 +1,21 @@ +package com.twix.goal_editor.model + +import androidx.compose.runtime.Immutable +import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.RepeatType +import com.twix.ui.base.State +import java.time.LocalDate + +@Immutable +data class GoalEditorUiState( + val selectedIcon: GoalIconType = GoalIconType.DEFAULT, + val goalTitle: String = "", + val selectedRepeatType: RepeatType = RepeatType.DAILY, + val repeatCount: Int = 0, + val startDate: LocalDate = LocalDate.now(), + val endDateEnabled: Boolean = false, + val endDate: LocalDate = LocalDate.now(), +) : State { + val isEnabled: Boolean + get() = goalTitle.isNotBlank() && repeatCount > 0 +} diff --git a/feature/goal-editor/src/main/java/com/twix/goal_editor/navigation/GoalEditorNavGraph.kt b/feature/goal-editor/src/main/java/com/twix/goal_editor/navigation/GoalEditorNavGraph.kt new file mode 100644 index 00000000..3de9e41c --- /dev/null +++ b/feature/goal-editor/src/main/java/com/twix/goal_editor/navigation/GoalEditorNavGraph.kt @@ -0,0 +1,29 @@ +package com.twix.goal_editor.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.twix.goal_editor.GoalEditorRoute +import com.twix.navigation.NavRoutes +import com.twix.navigation.base.NavGraphContributor + +object GoalEditorNavGraph : NavGraphContributor { + override val graphRoute: NavRoutes + get() = NavRoutes.GoalEditorGraph + override val startDestination: String + get() = NavRoutes.GoalEditorRoute.route + + override fun NavGraphBuilder.registerGraph(navController: NavHostController) { + navigation( + route = graphRoute.route, + startDestination = startDestination, + ) { + composable(NavRoutes.GoalEditorRoute.route) { + GoalEditorRoute( + navigateToBack = navController::popBackStack, + ) + } + } + } +} diff --git a/feature/main/src/main/java/com/twix/home/HomeScreen.kt b/feature/main/src/main/java/com/twix/home/HomeScreen.kt index 31a02b65..422138df 100644 --- a/feature/main/src/main/java/com/twix/home/HomeScreen.kt +++ b/feature/main/src/main/java/com/twix/home/HomeScreen.kt @@ -16,10 +16,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R +import com.twix.designsystem.theme.CommonColor import com.twix.designsystem.theme.GrayColor import com.twix.home.component.EmptyGoalGuide import com.twix.home.component.HomeTopBar @@ -33,6 +35,7 @@ import java.time.LocalDate fun HomeRoute( viewModel: HomeViewModel = koinViewModel(), onShowCalendarBottomSheet: () -> Unit, + navigateToGoalEditor: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -44,6 +47,7 @@ fun HomeRoute( onUpdateVisibleDate = { viewModel.dispatch(HomeIntent.UpdateVisibleDate(it)) }, onMoveToToday = { viewModel.dispatch(HomeIntent.MoveToToday) }, onShowCalendarBottomSheet = onShowCalendarBottomSheet, + onAddNewGoal = navigateToGoalEditor, ) } @@ -56,6 +60,7 @@ fun HomeScreen( onUpdateVisibleDate: (LocalDate) -> Unit, onMoveToToday: () -> Unit, onShowCalendarBottomSheet: () -> Unit, + onAddNewGoal: () -> Unit, ) { Box( modifier = @@ -94,9 +99,7 @@ fun HomeScreen( Modifier .align(Alignment.BottomEnd) .padding(bottom = 12.dp, end = 16.dp), - onClick = { - // TODO: 목표 추가 화면으로 이동 - }, + onClick = onAddNewGoal, ) } } @@ -121,6 +124,7 @@ private fun AddGoalButton( modifier = Modifier .size(40.dp), + colorFilter = ColorFilter.tint(CommonColor.White), ) } } diff --git a/feature/main/src/main/java/com/twix/main/MainScreen.kt b/feature/main/src/main/java/com/twix/main/MainScreen.kt index ac51fd2c..1e367709 100644 --- a/feature/main/src/main/java/com/twix/main/MainScreen.kt +++ b/feature/main/src/main/java/com/twix/main/MainScreen.kt @@ -24,7 +24,10 @@ import com.twix.main.model.MainTab import org.koin.androidx.compose.koinViewModel @Composable -fun MainRoute(viewModel: MainViewModel = koinViewModel()) { +fun MainRoute( + viewModel: MainViewModel = koinViewModel(), + navigateToGoalEditor: () -> Unit, +) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val homeViewModel: HomeViewModel = koinViewModel() @@ -32,6 +35,7 @@ fun MainRoute(viewModel: MainViewModel = koinViewModel()) { homeViewModel = homeViewModel, selectedTab = uiState.selectedTab, onTabClick = { tab -> viewModel.dispatch(MainIntent.SelectTab(tab)) }, + navigateToGoalEditor = navigateToGoalEditor, ) } @@ -40,6 +44,7 @@ private fun MainScreen( homeViewModel: HomeViewModel, selectedTab: MainTab, onTabClick: (MainTab) -> Unit, + navigateToGoalEditor: () -> Unit, ) { val calendarState by homeViewModel.calendarState.collectAsStateWithLifecycle() var showCalendarBottomSheet by remember { mutableStateOf(false) } @@ -73,6 +78,7 @@ private fun MainScreen( HomeRoute( viewModel = homeViewModel, onShowCalendarBottomSheet = { showCalendarBottomSheet = true }, + navigateToGoalEditor = navigateToGoalEditor, ) MainTab.STATS -> Box(modifier = Modifier.fillMaxSize()) MainTab.COUPLE -> Box(modifier = Modifier.fillMaxSize()) diff --git a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt index a96a4e1f..a5456e1e 100644 --- a/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt +++ b/feature/main/src/main/java/com/twix/main/navigation/MainNavGraph.kt @@ -20,7 +20,13 @@ object MainNavGraph : NavGraphContributor { startDestination = startDestination, ) { composable(NavRoutes.MainRoute.route) { - MainRoute() + MainRoute( + navigateToGoalEditor = { + navController.navigate(NavRoutes.GoalEditorRoute.route) { + launchSingleTop = true + } + }, + ) } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 05195c9d..a1898890 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,4 +37,5 @@ include(":core:network") include(":core:analytics") include(":feature:main") include(":feature:task-certification") +include(":feature:goal-editor") include(":core:result")