diff --git a/feature/main/src/main/java/com/twix/home/component/EmptyGoalGuide.kt b/core/design-system/src/main/java/com/twix/designsystem/components/goal/EmptyGoalGuide.kt similarity index 56% rename from feature/main/src/main/java/com/twix/home/component/EmptyGoalGuide.kt rename to core/design-system/src/main/java/com/twix/designsystem/components/goal/EmptyGoalGuide.kt index 16b116ca..a844a399 100644 --- a/feature/main/src/main/java/com/twix/home/component/EmptyGoalGuide.kt +++ b/core/design-system/src/main/java/com/twix/designsystem/components/goal/EmptyGoalGuide.kt @@ -1,4 +1,4 @@ -package com.twix.home.component +package com.twix.designsystem.components.goal import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -21,7 +20,11 @@ import com.twix.designsystem.theme.GrayColor import com.twix.domain.model.enums.AppTextStyle @Composable -fun EmptyGoalGuide(modifier: Modifier = Modifier) { +fun EmptyGoalGuide( + modifier: Modifier = Modifier, + text: String, + isDetail: Boolean = false, +) { Column( modifier = modifier @@ -30,30 +33,39 @@ fun EmptyGoalGuide(modifier: Modifier = Modifier) { verticalArrangement = Arrangement.Center, ) { Image( - painter = painterResource(R.drawable.ic_empty_face), + painter = painterResource(R.drawable.ic_empty_goal_home), contentDescription = "empty face", modifier = Modifier - .padding(horizontal = 9.dp, vertical = 6.dp) - .size(width = 34.dp, height = 40.dp), + .size(width = 181.dp, height = 111.dp), ) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(22.dp)) AppText( - text = stringResource(R.string.home_empty_goal_guide), + text = text, style = AppTextStyle.T2, - color = GrayColor.C200, + color = GrayColor.C400, ) - Spacer(Modifier.height(12.dp)) + if (!isDetail) { + Spacer(Modifier.height(5.dp)) - Image( - painter = painterResource(R.drawable.ic_empty_goal_arrow), - contentDescription = "empty goal arrow", - modifier = - Modifier - .offset(x = 32.dp), - ) + AppText( + text = stringResource(R.string.home_empty_goal_content), + style = AppTextStyle.C1, + color = GrayColor.C300, + ) + + Spacer(Modifier.height(50.dp)) + + Image( + painter = painterResource(R.drawable.ic_empty_goal_arrow), + contentDescription = "empty goal arrow", + modifier = + Modifier + .offset(x = 60.dp), + ) + } } } diff --git a/core/design-system/src/main/res/drawable/ic_empty_goal_arrow.xml b/core/design-system/src/main/res/drawable/ic_empty_goal_arrow.xml index 865123a1..6eaf7f7d 100644 --- a/core/design-system/src/main/res/drawable/ic_empty_goal_arrow.xml +++ b/core/design-system/src/main/res/drawable/ic_empty_goal_arrow.xml @@ -1,9 +1,23 @@ - + android:width="120dp" + android:height="120dp" + android:viewportWidth="120" + android:viewportHeight="120"> + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_empty_goal_home.xml b/core/design-system/src/main/res/drawable/ic_empty_goal_home.xml new file mode 100644 index 00000000..c1d34faf --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_empty_goal_home.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml index 821d6fbc..39ccb63d 100644 --- a/core/design-system/src/main/res/values/strings.xml +++ b/core/design-system/src/main/res/values/strings.xml @@ -40,6 +40,7 @@ 오늘 우리 목표 첫 목표를 세워볼까요? + + 버튼을 눌러 목표를 추가해보세요 코멘트추가 @@ -60,15 +61,20 @@ 오늘 우리 목표 다음 우리 목표 %s년 %02d월 %02d일 + 아직 목표가 없어요! 개인정보 처리방침 나의 버전 - + 종료 날짜가 시작 날짜보다 이전입니다. 목표 조회에 실패했습니다. 목표 생성에 실패했습니다. + 목표 수정에 실패했습니다. + 목표 삭제에 실패했습니다. + 목표 완료에 실패했습니다. + 목표를 입력해주세요. 로그아웃되었습니다. 로그아웃에 실패했습니다. 계정이 삭제되었습니다. diff --git a/core/network/src/main/java/com/twix/network/model/request/goal/mapper/GoalMapper.kt b/core/network/src/main/java/com/twix/network/model/request/goal/mapper/GoalMapper.kt index c93d9a12..c5afe713 100644 --- a/core/network/src/main/java/com/twix/network/model/request/goal/mapper/GoalMapper.kt +++ b/core/network/src/main/java/com/twix/network/model/request/goal/mapper/GoalMapper.kt @@ -1,7 +1,9 @@ package com.twix.network.model.request.goal.mapper import com.twix.domain.model.goal.CreateGoalParam +import com.twix.domain.model.goal.UpdateGoalParam import com.twix.network.model.request.goal.model.CreateGoalRequest +import com.twix.network.model.request.goal.model.UpdateGoalRequest fun CreateGoalParam.toRequest(): CreateGoalRequest = CreateGoalRequest( @@ -12,3 +14,12 @@ fun CreateGoalParam.toRequest(): CreateGoalRequest = startDate = startDate.toString(), endDate = endDate?.toString(), ) + +fun UpdateGoalParam.toRequest(): UpdateGoalRequest = + UpdateGoalRequest( + name = name, + icon = icon.toApi(), + repeatCycle = repeatCycle.toApi(), + repeatCount = repeatCount, + endDate = endDate?.toString(), + ) diff --git a/core/network/src/main/java/com/twix/network/model/request/goal/model/CreateGoalRequest.kt b/core/network/src/main/java/com/twix/network/model/request/goal/model/CreateGoalRequest.kt index f5c8c602..703ef36e 100644 --- a/core/network/src/main/java/com/twix/network/model/request/goal/model/CreateGoalRequest.kt +++ b/core/network/src/main/java/com/twix/network/model/request/goal/model/CreateGoalRequest.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable @Serializable data class CreateGoalRequest( - @SerialName("name") val name: String, + @SerialName("goalName") val name: String, @SerialName("icon") val icon: String, @SerialName("repeatCycle") val repeatCycle: String, @SerialName("repeatCount") val repeatCount: Int, diff --git a/core/network/src/main/java/com/twix/network/model/request/goal/model/UpdateGoalRequest.kt b/core/network/src/main/java/com/twix/network/model/request/goal/model/UpdateGoalRequest.kt new file mode 100644 index 00000000..db3fd8be --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/request/goal/model/UpdateGoalRequest.kt @@ -0,0 +1,13 @@ +package com.twix.network.model.request.goal.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateGoalRequest( + @SerialName("goalName") val name: String, + @SerialName("icon") val icon: String, + @SerialName("repeatCycle") val repeatCycle: String, + @SerialName("repeatCount") val repeatCount: Int, + @SerialName("endDate") val endDate: String? = null, +) diff --git a/core/network/src/main/java/com/twix/network/model/response/goal/mapper/GoalMapper.kt b/core/network/src/main/java/com/twix/network/model/response/goal/mapper/GoalMapper.kt index 6c62c5de..1b1702dc 100644 --- a/core/network/src/main/java/com/twix/network/model/response/goal/mapper/GoalMapper.kt +++ b/core/network/src/main/java/com/twix/network/model/response/goal/mapper/GoalMapper.kt @@ -3,13 +3,15 @@ package com.twix.network.model.response.goal.mapper import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.enums.GoalReactionType import com.twix.domain.model.enums.RepeatCycle -import com.twix.domain.model.goal.CreatedGoal import com.twix.domain.model.goal.Goal +import com.twix.domain.model.goal.GoalDetail import com.twix.domain.model.goal.GoalList +import com.twix.domain.model.goal.GoalSummary import com.twix.domain.model.goal.GoalVerification -import com.twix.network.model.response.goal.model.CreateGoalResponse +import com.twix.network.model.response.goal.model.GoalDetailResponse import com.twix.network.model.response.goal.model.GoalListResponse import com.twix.network.model.response.goal.model.GoalResponse +import com.twix.network.model.response.goal.model.GoalSummaryListResponse import com.twix.network.model.response.goal.model.VerificationResponse import java.time.LocalDate @@ -41,8 +43,8 @@ fun VerificationResponse.toDomainOrNull(): GoalVerification? = uploadedAt = uploadedAt, ) -fun CreateGoalResponse.toDomain(): CreatedGoal = - CreatedGoal( +fun GoalDetailResponse.toDomain(): GoalDetail = + GoalDetail( goalId = goalId, name = name, icon = GoalIconType.fromApi(icon), @@ -52,3 +54,15 @@ fun CreateGoalResponse.toDomain(): CreatedGoal = endDate = endDate?.let(LocalDate::parse), createdAt = createdAt, ) + +fun GoalSummaryListResponse.toDomain(): List = + this.goals.map { + GoalSummary( + goalId = it.goalId, + name = it.name, + icon = GoalIconType.fromApi(it.icon), + repeatCycle = RepeatCycle.fromApi(it.repeatCycle), + startDate = LocalDate.parse(it.startDate), + it.endDate?.let(LocalDate::parse), + ) + } diff --git a/core/network/src/main/java/com/twix/network/model/response/goal/model/CreateGoalResponse.kt b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalDetailResponse.kt similarity index 87% rename from core/network/src/main/java/com/twix/network/model/response/goal/model/CreateGoalResponse.kt rename to core/network/src/main/java/com/twix/network/model/response/goal/model/GoalDetailResponse.kt index 7150b5a6..00c8a7c1 100644 --- a/core/network/src/main/java/com/twix/network/model/response/goal/model/CreateGoalResponse.kt +++ b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalDetailResponse.kt @@ -4,9 +4,9 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class CreateGoalResponse( +data class GoalDetailResponse( @SerialName("goalId") val goalId: Long, - @SerialName("name") val name: String, + @SerialName("goalName") val name: String, @SerialName("icon") val icon: String, @SerialName("repeatCycle") val repeatCycle: String, @SerialName("repeatCount") val repeatCount: Int, diff --git a/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalResponse.kt b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalResponse.kt index e35d9047..a8a269b4 100644 --- a/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalResponse.kt +++ b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalResponse.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @Serializable data class GoalResponse( @SerialName("goalId") val goalId: Long, - @SerialName("name") val name: String, + @SerialName("goalName") val name: String, @SerialName("icon") val icon: String, @SerialName("repeatCycle") val repeatCycle: String, @SerialName("myCompleted") val myCompleted: Boolean, diff --git a/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalSummaryListResponse.kt b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalSummaryListResponse.kt new file mode 100644 index 00000000..222e76ab --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalSummaryListResponse.kt @@ -0,0 +1,9 @@ +package com.twix.network.model.response.goal.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GoalSummaryListResponse( + @SerialName("goals") val goals: List, +) diff --git a/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalSummaryResponse.kt b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalSummaryResponse.kt new file mode 100644 index 00000000..9fd0a11b --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/response/goal/model/GoalSummaryResponse.kt @@ -0,0 +1,14 @@ +package com.twix.network.model.response.goal.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GoalSummaryResponse( + @SerialName("goalId") val goalId: Long, + @SerialName("goalName") val name: String, + @SerialName("icon") val icon: String, + @SerialName("repeatCycle") val repeatCycle: String, + @SerialName("startDate") val startDate: String, + @SerialName("endDate") val endDate: String?, +) diff --git a/core/network/src/main/java/com/twix/network/service/GoalService.kt b/core/network/src/main/java/com/twix/network/service/GoalService.kt index 3a46205d..951cd333 100644 --- a/core/network/src/main/java/com/twix/network/service/GoalService.kt +++ b/core/network/src/main/java/com/twix/network/service/GoalService.kt @@ -1,11 +1,17 @@ package com.twix.network.service import com.twix.network.model.request.goal.model.CreateGoalRequest -import com.twix.network.model.response.goal.model.CreateGoalResponse +import com.twix.network.model.request.goal.model.UpdateGoalRequest +import com.twix.network.model.response.goal.model.GoalDetailResponse import com.twix.network.model.response.goal.model.GoalListResponse +import com.twix.network.model.response.goal.model.GoalSummaryListResponse import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.DELETE import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.PATCH import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path import de.jensklingenberg.ktorfit.http.Query interface GoalService { @@ -17,5 +23,31 @@ interface GoalService { @POST("api/v1/goals") suspend fun createGoal( @Body body: CreateGoalRequest, - ): CreateGoalResponse + ): GoalDetailResponse + + @PUT("api/v1/goals/{goalId}") + suspend fun updateGoal( + @Body body: UpdateGoalRequest, + @Path("goalId") goalId: Long, + ): GoalDetailResponse + + @GET("api/v1/goals/{goalId}") + suspend fun fetchGoalDetail( + @Path("goalId") goalId: Long, + ): GoalDetailResponse + + @DELETE("api/v1/goals/{goalId}") + suspend fun deleteGoal( + @Path("goalId") goalId: Long, + ) + + @PATCH("api/v1/goals/{goalId}/complete") + suspend fun completeGoal( + @Path("goalId") goalId: Long, + ) + + @GET("api/v1/goals/detail") + suspend fun fetchGoalSummaryList( + @Query("date") date: String, + ): GoalSummaryListResponse } diff --git a/core/util/src/main/java/com/twix/util/bus/GoalRefreshBus.kt b/core/util/src/main/java/com/twix/util/bus/GoalRefreshBus.kt index c2fd72a6..59630e06 100644 --- a/core/util/src/main/java/com/twix/util/bus/GoalRefreshBus.kt +++ b/core/util/src/main/java/com/twix/util/bus/GoalRefreshBus.kt @@ -4,12 +4,23 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow class GoalRefreshBus { - private val _events = + // 홈 화면 목표 리프레쉬 이벤트 + private val _goalEvents = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, ) - val events: SharedFlow = _events - fun notifyChanged() = _events.tryEmit(Unit) + // 편집 화면 목표 리프레쉬 이벤트 + private val _goalSummariesEvents = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + ) + val goalEvents: SharedFlow = _goalEvents + val goalSummariesEvents: SharedFlow = _goalSummariesEvents + + fun notifyGoalListChanged() = _goalEvents.tryEmit(Unit) + + fun notifyGoalSummariesChanged() = _goalSummariesEvents.tryEmit(Unit) } diff --git a/data/src/main/java/com/twix/data/repository/DefaultGoalRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultGoalRepository.kt index 1cf57d01..e25b193d 100644 --- a/data/src/main/java/com/twix/data/repository/DefaultGoalRepository.kt +++ b/data/src/main/java/com/twix/data/repository/DefaultGoalRepository.kt @@ -1,8 +1,10 @@ package com.twix.data.repository import com.twix.domain.model.goal.CreateGoalParam -import com.twix.domain.model.goal.CreatedGoal +import com.twix.domain.model.goal.GoalDetail import com.twix.domain.model.goal.GoalList +import com.twix.domain.model.goal.GoalSummary +import com.twix.domain.model.goal.UpdateGoalParam import com.twix.domain.repository.GoalRepository import com.twix.network.execute.safeApiCall import com.twix.network.model.request.goal.mapper.toRequest @@ -15,8 +17,27 @@ class DefaultGoalRepository( ) : GoalRepository { override suspend fun fetchGoalList(date: String): AppResult = safeApiCall { service.fetchGoals(date).toDomain() } - override suspend fun createGoal(param: CreateGoalParam): AppResult = + override suspend fun createGoal(param: CreateGoalParam): AppResult = safeApiCall { service.createGoal(param.toRequest()).toDomain() } + + override suspend fun updateGoal(param: UpdateGoalParam): AppResult = + safeApiCall { + service.updateGoal(body = param.toRequest(), goalId = param.goalId).toDomain() + } + + override suspend fun fetchGoalDetail(goalId: Long): AppResult = + safeApiCall { + service.fetchGoalDetail(goalId).toDomain() + } + + override suspend fun deleteGoal(goalId: Long): AppResult = safeApiCall { service.deleteGoal(goalId) } + + override suspend fun completeGoal(goalId: Long): AppResult = safeApiCall { service.completeGoal(goalId) } + + override suspend fun fetchGoalSummaryList(date: String): AppResult> = + safeApiCall { + service.fetchGoalSummaryList(date).toDomain() + } } diff --git a/domain/src/main/java/com/twix/domain/model/goal/CreatedGoal.kt b/domain/src/main/java/com/twix/domain/model/goal/GoalDetail.kt similarity index 94% rename from domain/src/main/java/com/twix/domain/model/goal/CreatedGoal.kt rename to domain/src/main/java/com/twix/domain/model/goal/GoalDetail.kt index fdb95343..b01c2e2b 100644 --- a/domain/src/main/java/com/twix/domain/model/goal/CreatedGoal.kt +++ b/domain/src/main/java/com/twix/domain/model/goal/GoalDetail.kt @@ -4,7 +4,7 @@ import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.enums.RepeatCycle import java.time.LocalDate -data class CreatedGoal( +data class GoalDetail( val goalId: Long, val name: String, val icon: GoalIconType, diff --git a/domain/src/main/java/com/twix/domain/model/goal/UpdateGoalParam.kt b/domain/src/main/java/com/twix/domain/model/goal/UpdateGoalParam.kt new file mode 100644 index 00000000..e2c281e3 --- /dev/null +++ b/domain/src/main/java/com/twix/domain/model/goal/UpdateGoalParam.kt @@ -0,0 +1,14 @@ +package com.twix.domain.model.goal + +import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.RepeatCycle +import java.time.LocalDate + +data class UpdateGoalParam( + val goalId: Long, + val name: String, + val icon: GoalIconType, + val repeatCycle: RepeatCycle, + val repeatCount: Int, + val endDate: LocalDate?, +) diff --git a/domain/src/main/java/com/twix/domain/repository/GoalRepository.kt b/domain/src/main/java/com/twix/domain/repository/GoalRepository.kt index c79b7732..40e7a2d4 100644 --- a/domain/src/main/java/com/twix/domain/repository/GoalRepository.kt +++ b/domain/src/main/java/com/twix/domain/repository/GoalRepository.kt @@ -1,12 +1,24 @@ package com.twix.domain.repository import com.twix.domain.model.goal.CreateGoalParam -import com.twix.domain.model.goal.CreatedGoal +import com.twix.domain.model.goal.GoalDetail import com.twix.domain.model.goal.GoalList +import com.twix.domain.model.goal.GoalSummary +import com.twix.domain.model.goal.UpdateGoalParam import com.twix.result.AppResult interface GoalRepository { suspend fun fetchGoalList(date: String): AppResult - suspend fun createGoal(param: CreateGoalParam): AppResult + suspend fun createGoal(param: CreateGoalParam): AppResult + + suspend fun updateGoal(param: UpdateGoalParam): AppResult + + suspend fun fetchGoalDetail(goalId: Long): AppResult + + suspend fun deleteGoal(goalId: Long): AppResult + + suspend fun completeGoal(goalId: Long): AppResult + + suspend fun fetchGoalSummaryList(date: String): AppResult> } 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 index 872db8e9..31a55405 100644 --- 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 @@ -34,7 +34,9 @@ sealed interface GoalEditorIntent : Intent { val enabled: Boolean, ) : GoalEditorIntent - data object Save : GoalEditorIntent + data class Save( + val id: Long, + ) : GoalEditorIntent data class InitGoal( val id: Long, 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 index 89e116e4..15a1678f 100644 --- 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 @@ -105,7 +105,7 @@ fun GoalEditorRoute( onCommitStartDate = { viewModel.dispatch(GoalEditorIntent.SetStartDate(it)) }, onCommitRepeatCount = { viewModel.dispatch(GoalEditorIntent.SetRepeatCount(it)) }, onToggleEndDateEnabled = { viewModel.dispatch(GoalEditorIntent.SetEndDateEnabled(it)) }, - onComplete = { viewModel.dispatch(GoalEditorIntent.Save) }, + onComplete = { viewModel.dispatch(GoalEditorIntent.Save(goalId)) }, ) } 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 index 8958f0e4..39710943 100644 --- 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 @@ -1,19 +1,16 @@ 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.GoalReactionType import com.twix.domain.model.enums.RepeatCycle import com.twix.domain.model.goal.CreateGoalParam -import com.twix.domain.model.goal.Goal -import com.twix.domain.model.goal.GoalVerification +import com.twix.domain.model.goal.GoalDetail +import com.twix.domain.model.goal.UpdateGoalParam import com.twix.domain.repository.GoalRepository import com.twix.goal_editor.model.GoalEditorUiState import com.twix.ui.base.BaseViewModel import com.twix.util.bus.GoalRefreshBus -import kotlinx.coroutines.launch import java.time.LocalDate class GoalEditorViewModel( @@ -24,7 +21,7 @@ class GoalEditorViewModel( ) { override suspend fun handleIntent(intent: GoalEditorIntent) { when (intent) { - GoalEditorIntent.Save -> save() + is GoalEditorIntent.Save -> save(intent.id) is GoalEditorIntent.SetIcon -> setIcon(intent.icon) is GoalEditorIntent.SetEndDate -> setEndDate(intent.endDate) is GoalEditorIntent.SetRepeatCount -> setRepeatCount(intent.repeatCount) @@ -68,66 +65,60 @@ class GoalEditorViewModel( reduce { copy(endDateEnabled = enabled) } } - private fun save() { - if (!currentState.isEnabled) return + private fun setGoal(goal: GoalDetail) { + reduce { + copy( + goalTitle = goal.name, + selectedIcon = goal.icon, + selectedRepeatCycle = goal.repeatCycle, + repeatCount = goal.repeatCount, + endDate = goal.endDate ?: LocalDate.now(), + endDateEnabled = goal.endDate != null, + ) + } + } - if (currentState.endDateEnabled && currentState.endDate.isBefore(currentState.startDate)) { - viewModelScope.launch { - emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_end_date_before_start_date, ToastType.ERROR)) - } + private suspend fun save(id: Long) { + if (!currentState.isEnabled) { + emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_input_goal_title, ToastType.ERROR)) return } - launchResult( - block = { goalRepository.createGoal(currentState.toCreateParam()) }, - onSuccess = { - goalRefreshBus.notifyChanged() - tryEmitSideEffect(GoalEditorSideEffect.NavigateToHome) - }, - onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_create_goal_failed, ToastType.ERROR)) }, - ) - } + if (currentState.endDateEnabled && currentState.endDate.isBefore(currentState.startDate)) { + emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_end_date_before_start_date, ToastType.ERROR)) + return + } - private fun initGoal(id: Long) { - val goal = - Goal( - goalId = 4, - name = "밥무라", - icon = GoalIconType.DEFAULT, - repeatCycle = RepeatCycle.WEEKLY, - myCompleted = true, - partnerCompleted = true, - myVerification = - GoalVerification( - photologId = 1, - imageUrl = "https://picsum.photos/400/300", - comment = null, - reaction = GoalReactionType.LOVE, - uploadedAt = "2023-05-05", - ), - partnerVerification = - GoalVerification( - photologId = 1, - imageUrl = "https://picsum.photos/400/300", - comment = null, - reaction = GoalReactionType.LOVE, - uploadedAt = "2023-05-05", - ), + if (id == -1L) { + launchResult( + block = { goalRepository.createGoal(currentState.toCreateParam()) }, + onSuccess = { + goalRefreshBus.notifyGoalListChanged() + tryEmitSideEffect(GoalEditorSideEffect.NavigateToHome) + }, + onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_create_goal_failed, ToastType.ERROR)) }, ) - - reduce { - copy( - goalTitle = goal.name, - selectedIcon = goal.icon, - selectedRepeatCycle = goal.repeatCycle, - repeatCount = 4, - startDate = LocalDate.now(), - endDate = LocalDate.now().plusWeeks(1), - endDateEnabled = true, + } else { + launchResult( + block = { goalRepository.updateGoal(currentState.toUpdateParam(id)) }, + onSuccess = { + goalRefreshBus.notifyGoalListChanged() + goalRefreshBus.notifyGoalSummariesChanged() + tryEmitSideEffect(GoalEditorSideEffect.NavigateToHome) + }, + onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_update_goal_failed, ToastType.ERROR)) }, ) } } + private fun initGoal(id: Long) { + launchResult( + block = { goalRepository.fetchGoalDetail(id) }, + onSuccess = { setGoal(it) }, + onError = { emitSideEffect(GoalEditorSideEffect.ShowToast(R.string.toast_goal_fetch_failed, ToastType.ERROR)) }, + ) + } + private fun GoalEditorUiState.toCreateParam(): CreateGoalParam = CreateGoalParam( name = goalTitle.trim(), @@ -137,4 +128,14 @@ class GoalEditorViewModel( startDate = startDate, endDate = if (endDateEnabled) endDate else null, ) + + private fun GoalEditorUiState.toUpdateParam(id: Long): UpdateGoalParam = + UpdateGoalParam( + goalId = id, + name = goalTitle.trim(), + icon = selectedIcon, + repeatCycle = selectedRepeatCycle, + repeatCount = repeatCount, + endDate = if (endDateEnabled) endDate else null, + ) } 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 index c7116a3e..80070668 100644 --- 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 @@ -65,9 +65,9 @@ fun GoalInfoCard( onShowRepeatCountBottomSheet = onShowRepeatCountBottomSheet, ) - HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) - if (!isEdit) { + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + DateSettings( date = startDate, onShowCalendarBottomSheet = { onShowCalendarBottomSheet(false) }, 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 index 48669b98..f49883a3 100644 --- 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 @@ -1,17 +1,23 @@ package com.twix.goal_editor.component +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime 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.LaunchedEffect 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.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -19,15 +25,43 @@ import androidx.compose.ui.unit.dp import com.twix.designsystem.R import com.twix.designsystem.components.text_field.UnderlineTextField +@OptIn(ExperimentalLayoutApi::class) @Composable fun GoalTextField( value: String, onCommitTitle: (String) -> Unit, ) { val focusManager = LocalFocusManager.current + val density = LocalDensity.current var internalValue by rememberSaveable(value) { mutableStateOf(value) } - // 초기에 무의미하게 commit 되는 것을 방지하는 상태 변수 - var wasFocused by remember { mutableStateOf(false) } + var isFocused by remember { mutableStateOf(false) } + var lastCommitted by remember(value) { mutableStateOf(value.trim()) } + + fun commitIfChanged() { + val trimmed = internalValue.trim() + if (trimmed != lastCommitted) { + lastCommitted = trimmed + onCommitTitle(trimmed) + } + } + + val imeVisibleState = + remember { + mutableStateOf(false) + } + + imeVisibleState.value = WindowInsets.ime.getBottom(density) > 0 + LaunchedEffect(isFocused) { + var prev = imeVisibleState.value + snapshotFlow { imeVisibleState.value } + .collect { now -> + if (prev && !now && isFocused) { + commitIfChanged() + focusManager.clearFocus(force = true) + } + prev = now + } + } UnderlineTextField( modifier = @@ -35,15 +69,19 @@ fun GoalTextField( .padding(horizontal = 20.dp) .fillMaxWidth() .onFocusChanged { state -> - if (wasFocused && !state.isFocused) { - onCommitTitle(internalValue.trim()) - } - wasFocused = state.isFocused + isFocused = state.isFocused + if (!state.isFocused) commitIfChanged() }, value = internalValue, placeHolder = stringResource(R.string.goal_editor_text_field_placeholder), onValueChange = { internalValue = it }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + keyboardActions = + KeyboardActions( + onDone = { + commitIfChanged() + focusManager.clearFocus(force = true) + }, + ), ) } 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 index 8add180f..21841e59 100644 --- 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 @@ -17,5 +17,5 @@ data class GoalEditorUiState( val endDate: LocalDate = LocalDate.now(), ) : State { val isEnabled: Boolean - get() = goalTitle.isNotBlank() && (selectedRepeatCycle == RepeatCycle.DAILY || repeatCount > 0) + get() = goalTitle.isNotBlank() } diff --git a/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageIntent.kt b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageIntent.kt index 8203abea..86e8f6f7 100644 --- a/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageIntent.kt +++ b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageIntent.kt @@ -19,4 +19,27 @@ sealed interface GoalManageIntent : Intent { data object PreviousWeek : GoalManageIntent data object NextWeek : GoalManageIntent + + // UI 제어 + data class OpenMenu( + val goalId: Long, + ) : GoalManageIntent + + data object CloseMenu : GoalManageIntent + + data class ShowEndDialog( + val goalId: Long, + ) : GoalManageIntent + + data object DismissEndDialog : GoalManageIntent + + data class ShowDeleteDialog( + val goalId: Long, + ) : GoalManageIntent + + data object DismissDeleteDialog : GoalManageIntent + + data class EditGoal( + val goalId: Long, + ) : GoalManageIntent } diff --git a/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageScreen.kt b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageScreen.kt index 7649de79..4ff3c63c 100644 --- a/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageScreen.kt +++ b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageScreen.kt @@ -25,9 +25,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -37,11 +39,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R import com.twix.designsystem.components.calendar.WeeklyCalendar import com.twix.designsystem.components.dialog.CommonDialog +import com.twix.designsystem.components.goal.EmptyGoalGuide import com.twix.designsystem.components.goal.GoalCardFrame import com.twix.designsystem.components.popup.CommonPopup import com.twix.designsystem.components.popup.CommonPopupDivider import com.twix.designsystem.components.popup.CommonPopupItem 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.components.topbar.CommonTopBar import com.twix.designsystem.extension.label import com.twix.designsystem.extension.toRes @@ -51,49 +56,91 @@ import com.twix.domain.model.enums.AppTextStyle import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.goal.GoalSummary import com.twix.goal_manage.model.GoalManageUiState +import com.twix.ui.base.ObserveAsEvents import com.twix.ui.extension.noRippleClickable import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject import java.time.LocalDate @Composable fun GoalManageRoute( selectedDate: LocalDate, + toastManager: ToastManager = koinInject(), viewModel: GoalManageViewModel = koinViewModel(), popBackStack: () -> Unit, navigateToGoalEditor: (Long) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val currentContext by rememberUpdatedState(context) LaunchedEffect(selectedDate) { viewModel.dispatch(GoalManageIntent.SetSelectedDate(selectedDate)) } + ObserveAsEvents(viewModel.sideEffect) { effect -> + when (effect) { + is GoalManageSideEffect.ShowToast -> toastManager.tryShow(ToastData(currentContext.getString(effect.resId), effect.type)) + is GoalManageSideEffect.NavigateToGoalEditor -> navigateToGoalEditor(effect.goalId) + } + } + GoalManageScreen( uiState = uiState, + openedMenuGoalId = uiState.openedMenuGoalId, + pendingIds = uiState.pendingGoalIds, onBack = popBackStack, onSelectDate = { viewModel.dispatch(GoalManageIntent.SetSelectedDate(it)) }, onPreviousWeek = { viewModel.dispatch(GoalManageIntent.PreviousWeek) }, onNextWeek = { viewModel.dispatch(GoalManageIntent.NextWeek) }, - onEdit = navigateToGoalEditor, - onDelete = { viewModel.dispatch(GoalManageIntent.DeleteGoal(it)) }, - onEnd = { viewModel.dispatch(GoalManageIntent.EndGoal(it)) }, + // 팝업 메뉴 + onOpenMenu = { viewModel.dispatch(GoalManageIntent.OpenMenu(it)) }, + onCloseMenu = { viewModel.dispatch(GoalManageIntent.CloseMenu) }, + // 팝업 아이템에서 요청 + onRequestEnd = { viewModel.dispatch(GoalManageIntent.ShowEndDialog(it)) }, + onRequestDelete = { viewModel.dispatch(GoalManageIntent.ShowDeleteDialog(it)) }, + // 다이얼로그 confirm + onConfirmEnd = { viewModel.dispatch(GoalManageIntent.EndGoal(it)) }, + onConfirmDelete = { viewModel.dispatch(GoalManageIntent.DeleteGoal(it)) }, + // 다이얼로그 dismiss + onDismissEndDialog = { viewModel.dispatch(GoalManageIntent.DismissEndDialog) }, + onDismissDeleteDialog = { viewModel.dispatch(GoalManageIntent.DismissDeleteDialog) }, + // 수정 + onEdit = { viewModel.dispatch(GoalManageIntent.EditGoal(it)) }, ) } @Composable private fun GoalManageScreen( uiState: GoalManageUiState, + openedMenuGoalId: Long?, onBack: () -> Unit, onSelectDate: (LocalDate) -> Unit, onPreviousWeek: () -> Unit, onNextWeek: () -> Unit, - onEdit: (Long) -> Unit = {}, - onDelete: (Long) -> Unit = {}, - onEnd: (Long) -> Unit = {}, + onEdit: (Long) -> Unit, + onRequestDelete: (Long) -> Unit, + onRequestEnd: (Long) -> Unit, + onConfirmDelete: (Long) -> Unit, + onConfirmEnd: (Long) -> Unit, + onDismissDeleteDialog: () -> Unit, + onDismissEndDialog: () -> Unit, + onOpenMenu: (Long) -> Unit, + onCloseMenu: () -> Unit, + pendingIds: Set, ) { - var showEndGoalDialog by remember { mutableStateOf(false) } - var showDeleteGoalDialog by remember { mutableStateOf(false) } - var selectedGoal: GoalSummary? by remember { mutableStateOf(null) } + val endDialog = uiState.endDialog + val deleteDialog = uiState.deleteDialog + + var endDialogSnapshot by remember { mutableStateOf(endDialog) } + var deleteDialogSnapshot by remember { mutableStateOf(deleteDialog) } + + LaunchedEffect(endDialog) { + if (endDialog != null) endDialogSnapshot = endDialog + } + LaunchedEffect(deleteDialog) { + if (deleteDialog != null) deleteDialogSnapshot = deleteDialog + } Box(modifier = Modifier.fillMaxSize()) { Column( @@ -127,63 +174,71 @@ private fun GoalManageScreen( onNextWeek = onNextWeek, ) - GoalSummaryList( - modifier = - Modifier - .padding(horizontal = 20.dp) - .weight(1f), - summaryList = uiState.goalSummaries, - onEdit = onEdit, - onDelete = { - showDeleteGoalDialog = true - selectedGoal = it - }, - onEnd = { - showEndGoalDialog = true - selectedGoal = it - }, - ) + if (uiState.goalSummaries.isEmpty()) { + EmptyGoalGuide( + modifier = + Modifier + .padding(top = 128.dp), + text = stringResource(R.string.goal_detail_empty_goal_guide), + isDetail = true, + ) + } else { + GoalSummaryList( + modifier = + Modifier + .padding(horizontal = 20.dp) + .weight(1f), + summaryList = uiState.goalSummaries, + openedMenuGoalId = openedMenuGoalId, + pendingIds = pendingIds, + onOpenMenu = onOpenMenu, + onCloseMenu = onCloseMenu, + onEdit = onEdit, + onRequestDelete = onRequestDelete, + onRequestEnd = onRequestEnd, + ) + } } CommonDialog( - visible = showEndGoalDialog, + visible = endDialog != null, confirmText = stringResource(R.string.action_complete_goal), dismissText = stringResource(R.string.word_cancel), - onDismissRequest = { showEndGoalDialog = false }, + onDismissRequest = onDismissEndDialog, onConfirm = { - showEndGoalDialog = false - selectedGoal?.let { onEnd(it.goalId) } + val id = endDialog?.goalId + onDismissEndDialog() + id?.let(onConfirmEnd) }, - onDismiss = { showEndGoalDialog = false }, + onDismiss = onDismissEndDialog, content = { - selectedGoal?.let { - GoalSummaryDialogContent( - title = stringResource(R.string.dialog_end_goal_title, it.name), - content = stringResource(R.string.dialog_end_goal_content), - icon = it.icon, - ) - } + val dialog = endDialogSnapshot ?: return@CommonDialog + GoalSummaryDialogContent( + title = stringResource(R.string.dialog_end_goal_title, dialog.name), + content = stringResource(R.string.dialog_end_goal_content), + icon = dialog.icon, + ) }, ) CommonDialog( - visible = showDeleteGoalDialog, + visible = deleteDialog != null, confirmText = stringResource(R.string.word_delete), dismissText = stringResource(R.string.word_cancel), - onDismissRequest = { showDeleteGoalDialog = false }, + onDismissRequest = onDismissDeleteDialog, onConfirm = { - showDeleteGoalDialog = false - selectedGoal?.let { onDelete(it.goalId) } + val id = deleteDialog?.goalId + onDismissDeleteDialog() + id?.let(onConfirmDelete) }, - onDismiss = { showDeleteGoalDialog = false }, + onDismiss = onDismissDeleteDialog, content = { - selectedGoal?.let { - GoalSummaryDialogContent( - title = stringResource(R.string.dialog_delete_goal_title, it.name), - content = stringResource(R.string.dialog_delete_goal_content), - icon = it.icon, - ) - } + val dialog = deleteDialogSnapshot ?: return@CommonDialog + GoalSummaryDialogContent( + title = stringResource(R.string.dialog_delete_goal_title, dialog.name), + content = stringResource(R.string.dialog_delete_goal_content), + icon = dialog.icon, + ) }, ) } @@ -193,9 +248,13 @@ private fun GoalManageScreen( private fun GoalSummaryList( modifier: Modifier = Modifier, summaryList: List, + openedMenuGoalId: Long?, + pendingIds: Set, + onOpenMenu: (Long) -> Unit, + onCloseMenu: () -> Unit, onEdit: (Long) -> Unit = {}, - onDelete: (GoalSummary) -> Unit = {}, - onEnd: (GoalSummary) -> Unit = {}, + onRequestDelete: (Long) -> Unit = {}, + onRequestEnd: (Long) -> Unit = {}, ) { LazyColumn( modifier = modifier, @@ -205,9 +264,13 @@ private fun GoalSummaryList( items(summaryList, key = { it.goalId }) { item -> GoalSummaryItem( item = item, + openedMenuGoalId = openedMenuGoalId, + onOpenMenu = onOpenMenu, + onCloseMenu = onCloseMenu, onEdit = onEdit, - onDelete = onDelete, - onEnd = onEnd, + onShowDeleteDialog = onRequestDelete, + onShowEndDialog = onRequestEnd, + isPending = item.goalId in pendingIds, ) } } @@ -216,12 +279,15 @@ private fun GoalSummaryList( @Composable private fun GoalSummaryItem( item: GoalSummary, - onEdit: (Long) -> Unit = {}, - onDelete: (GoalSummary) -> Unit = {}, - onEnd: (GoalSummary) -> Unit = {}, + openedMenuGoalId: Long?, + onOpenMenu: (Long) -> Unit, + onCloseMenu: () -> Unit, + onEdit: (Long) -> Unit, + onShowEndDialog: (Long) -> Unit, + onShowDeleteDialog: (Long) -> Unit, + isPending: Boolean, ) { - var menuExpanded by remember { mutableStateOf(false) } - var anchorOffset by remember { mutableStateOf(IntOffset(x = -180, y = 68)) } + val menuVisible = openedMenuGoalId == item.goalId GoalCardFrame( goalName = item.name, @@ -236,13 +302,13 @@ private fun GoalSummaryItem( modifier = Modifier .size(24.dp) - .noRippleClickable(onClick = { menuExpanded = true }), + .noRippleClickable(enabled = !isPending, onClick = { onOpenMenu(item.goalId) }), ) CommonPopup( - visible = menuExpanded, - anchorOffset = anchorOffset, - onDismiss = { menuExpanded = false }, + visible = menuVisible, + anchorOffset = IntOffset(x = -180, y = 68), + onDismiss = onCloseMenu, ) { Column( modifier = @@ -255,23 +321,21 @@ private fun GoalSummaryItem( text = stringResource(R.string.action_edit), onClick = { onEdit(item.goalId) - menuExpanded = false + onCloseMenu() }, ) CommonPopupDivider() CommonPopupItem( text = stringResource(R.string.action_finish), onClick = { - onEnd(item) - menuExpanded = false + onShowEndDialog(item.goalId) }, ) CommonPopupDivider() CommonPopupItem( text = stringResource(R.string.action_delete), onClick = { - onDelete(item) - menuExpanded = false + onShowDeleteDialog(item.goalId) }, ) } diff --git a/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageSideEffect.kt b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageSideEffect.kt new file mode 100644 index 00000000..3420815d --- /dev/null +++ b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageSideEffect.kt @@ -0,0 +1,16 @@ +package com.twix.goal_manage + +import androidx.annotation.StringRes +import com.twix.designsystem.components.toast.model.ToastType +import com.twix.ui.base.SideEffect + +sealed interface GoalManageSideEffect : SideEffect { + data class ShowToast( + @param:StringRes val resId: Int, + val type: ToastType, + ) : GoalManageSideEffect + + data class NavigateToGoalEditor( + val goalId: Long, + ) : GoalManageSideEffect +} diff --git a/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageViewModel.kt b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageViewModel.kt index b42c0254..e086c040 100644 --- a/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageViewModel.kt +++ b/feature/goal-manage/src/main/java/com/twix/goal_manage/GoalManageViewModel.kt @@ -1,15 +1,30 @@ package com.twix.goal_manage +import androidx.lifecycle.viewModelScope +import com.twix.designsystem.R +import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.model.enums.WeekNavigation import com.twix.domain.repository.GoalRepository +import com.twix.goal_manage.model.GoalDialogState import com.twix.goal_manage.model.GoalManageUiState +import com.twix.goal_manage.model.RemovedGoal import com.twix.ui.base.BaseViewModel -import com.twix.ui.base.NoSideEffect +import com.twix.util.bus.GoalRefreshBus +import kotlinx.coroutines.launch import java.time.LocalDate class GoalManageViewModel( private val goalRepository: GoalRepository, -) : BaseViewModel(GoalManageUiState()) { + private val goalRefreshBus: GoalRefreshBus, +) : BaseViewModel(GoalManageUiState()) { + init { + viewModelScope.launch { + goalRefreshBus.goalSummariesEvents.collect { + fetchGoalSummaryList(currentState.selectedDate) + } + } + } + override suspend fun handleIntent(intent: GoalManageIntent) { when (intent) { is GoalManageIntent.EndGoal -> endGoal(intent.id) @@ -17,21 +32,129 @@ class GoalManageViewModel( is GoalManageIntent.SetSelectedDate -> setSelectedDate(intent.date) GoalManageIntent.NextWeek -> shiftWeek(WeekNavigation.NEXT) GoalManageIntent.PreviousWeek -> shiftWeek(WeekNavigation.PREVIOUS) + is GoalManageIntent.OpenMenu -> reduce { copy(openedMenuGoalId = intent.goalId) } + GoalManageIntent.CloseMenu -> reduce { copy(openedMenuGoalId = null) } + is GoalManageIntent.ShowEndDialog -> { + if (intent.goalId in currentState.pendingGoalIds) return + val goal = currentState.goalSummaries.firstOrNull { it.goalId == intent.goalId } ?: return + reduce { + copy( + endDialog = GoalDialogState(goal.goalId, goal.name, goal.icon), + openedMenuGoalId = null, + ) + } + } + GoalManageIntent.DismissEndDialog -> reduce { copy(endDialog = null) } + is GoalManageIntent.ShowDeleteDialog -> { + if (intent.goalId in currentState.pendingGoalIds) return + val goal = currentState.goalSummaries.firstOrNull { it.goalId == intent.goalId } ?: return + reduce { + copy( + deleteDialog = GoalDialogState(goal.goalId, goal.name, goal.icon), + openedMenuGoalId = null, + ) + } + } + GoalManageIntent.DismissDeleteDialog -> reduce { copy(deleteDialog = null) } + is GoalManageIntent.EditGoal -> { + reduce { copy(openedMenuGoalId = null) } + emitSideEffect(GoalManageSideEffect.NavigateToGoalEditor(intent.goalId)) + } } } private fun endGoal(id: Long) { - // TODO: 서버 통신 로직 추가 + if (id in currentState.pendingGoalIds) return + + val removed = removeGoalOptimistically(id) ?: return + + markPending(id, true) + + launchResult( + block = { goalRepository.completeGoal(id) }, + onSuccess = { + goalRefreshBus.notifyGoalListChanged() + markPending(id, false) + }, + onError = { + markPending(id, false) + restoreGoal(removed) + emitSideEffect(GoalManageSideEffect.ShowToast(R.string.toast_complete_goal_failed, ToastType.ERROR)) + }, + ) } private fun deleteGoal(id: Long) { - val newSummaries = currentState.goalSummaries.filter { it.goalId != id } - reduce { copy(goalSummaries = newSummaries) } - // TODO: 서버 통신 로직 추가 + if (id in currentState.pendingGoalIds) return + + val removed = removeGoalOptimistically(id) ?: return + + markPending(id, true) + + launchResult( + block = { goalRepository.deleteGoal(id) }, + onSuccess = { + goalRefreshBus.notifyGoalListChanged() + markPending(id, false) + }, + onError = { + markPending(id, false) + restoreGoal(removed) + emitSideEffect(GoalManageSideEffect.ShowToast(R.string.toast_delete_goal_failed, ToastType.ERROR)) + }, + ) + } + + private fun removeGoalOptimistically(id: Long): RemovedGoal? { + val index = currentState.goalSummaries.indexOfFirst { it.goalId == id } + if (index == -1) return null + val item = currentState.goalSummaries[index] + + reduce { + copy( + goalSummaries = goalSummaries.filterNot { it.goalId == id }, + openedMenuGoalId = if (openedMenuGoalId == id) null else openedMenuGoalId, + ) + } + return RemovedGoal(index, item) } private fun setSelectedDate(date: LocalDate) { - reduce { copy(selectedDate = date) } + if (currentState.selectedDate == date && currentState.isInitialized) return + reduce { copy(selectedDate = date, isInitialized = true) } + + fetchGoalSummaryList(date) + } + + private fun fetchGoalSummaryList(date: LocalDate) { + launchResult( + block = { goalRepository.fetchGoalSummaryList(date.toString()) }, + onSuccess = { + reduce { copy(goalSummaries = it) } + }, + onError = { emitSideEffect(GoalManageSideEffect.ShowToast(R.string.toast_goal_fetch_failed, ToastType.ERROR)) }, + ) + } + + private fun restoreGoal(removed: RemovedGoal) { + reduce { + if (goalSummaries.any { it.goalId == removed.item.goalId }) return@reduce this + val list = goalSummaries.toMutableList() + val safeIndex = removed.index.coerceIn(0, list.size) + list.add(safeIndex, removed.item) + copy(goalSummaries = list) + } + } + + private fun markPending( + id: Long, + pending: Boolean, + ) { + reduce { + copy( + pendingGoalIds = if (pending) pendingGoalIds + id else pendingGoalIds - id, + ) + } } private fun shiftWeek(action: WeekNavigation) { diff --git a/feature/goal-manage/src/main/java/com/twix/goal_manage/model/GoalDialogState.kt b/feature/goal-manage/src/main/java/com/twix/goal_manage/model/GoalDialogState.kt new file mode 100644 index 00000000..455b7bbd --- /dev/null +++ b/feature/goal-manage/src/main/java/com/twix/goal_manage/model/GoalDialogState.kt @@ -0,0 +1,11 @@ +package com.twix.goal_manage.model + +import androidx.compose.runtime.Immutable +import com.twix.domain.model.enums.GoalIconType + +@Immutable +data class GoalDialogState( + val goalId: Long, + val name: String, + val icon: GoalIconType, +) diff --git a/feature/goal-manage/src/main/java/com/twix/goal_manage/model/GoalManageUiState.kt b/feature/goal-manage/src/main/java/com/twix/goal_manage/model/GoalManageUiState.kt index 41526b28..d001c522 100644 --- a/feature/goal-manage/src/main/java/com/twix/goal_manage/model/GoalManageUiState.kt +++ b/feature/goal-manage/src/main/java/com/twix/goal_manage/model/GoalManageUiState.kt @@ -1,49 +1,18 @@ package com.twix.goal_manage.model import androidx.compose.runtime.Immutable -import com.twix.domain.model.enums.GoalIconType -import com.twix.domain.model.enums.RepeatCycle import com.twix.domain.model.goal.GoalSummary import com.twix.ui.base.State import java.time.LocalDate @Immutable data class GoalManageUiState( + val isInitialized: Boolean = false, val selectedDate: LocalDate = LocalDate.now(), val referenceDate: LocalDate = LocalDate.now(), // 7일 달력을 생성하기 위한 레퍼런스 날짜 - val goalSummaries: List = - listOf( - GoalSummary( - goalId = 1, - name = "운동", - icon = GoalIconType.EXERCISE, - repeatCycle = RepeatCycle.DAILY, - startDate = LocalDate.now(), - endDate = LocalDate.now(), - ), - GoalSummary( - goalId = 2, - name = "운동", - icon = GoalIconType.EXERCISE, - repeatCycle = RepeatCycle.DAILY, - startDate = LocalDate.now(), - endDate = null, - ), - GoalSummary( - goalId = 3, - name = "운동", - icon = GoalIconType.EXERCISE, - repeatCycle = RepeatCycle.DAILY, - startDate = LocalDate.now(), - endDate = LocalDate.now(), - ), - GoalSummary( - goalId = 4, - name = "운동", - icon = GoalIconType.EXERCISE, - repeatCycle = RepeatCycle.DAILY, - startDate = LocalDate.now(), - endDate = LocalDate.now(), - ), - ), + val goalSummaries: List = emptyList(), + val pendingGoalIds: Set = emptySet(), // 중복 요청 방지 + val openedMenuGoalId: Long? = null, // 팝업 현재 열린 goalId + val endDialog: GoalDialogState? = null, + val deleteDialog: GoalDialogState? = null, ) : State diff --git a/feature/goal-manage/src/main/java/com/twix/goal_manage/model/RemovedGoal.kt b/feature/goal-manage/src/main/java/com/twix/goal_manage/model/RemovedGoal.kt new file mode 100644 index 00000000..8b39cadf --- /dev/null +++ b/feature/goal-manage/src/main/java/com/twix/goal_manage/model/RemovedGoal.kt @@ -0,0 +1,8 @@ +package com.twix.goal_manage.model + +import com.twix.domain.model.goal.GoalSummary + +data class RemovedGoal( + val index: Int, + val item: GoalSummary, +) 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 b4b19eff..47903e78 100644 --- a/feature/main/src/main/java/com/twix/home/HomeScreen.kt +++ b/feature/main/src/main/java/com/twix/home/HomeScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R import com.twix.designsystem.components.calendar.WeeklyCalendar +import com.twix.designsystem.components.goal.EmptyGoalGuide import com.twix.designsystem.components.goal.GoalCardFrame import com.twix.designsystem.components.goal.GoalCheckIndicator import com.twix.designsystem.components.text.AppText @@ -37,7 +38,6 @@ import com.twix.designsystem.theme.GrayColor import com.twix.domain.model.enums.AppTextStyle import com.twix.domain.model.goal.Goal import com.twix.domain.model.goal.checkState -import com.twix.home.component.EmptyGoalGuide import com.twix.home.component.GoalVerifications import com.twix.home.component.HomeTopBar import com.twix.home.model.HomeUiState @@ -112,7 +112,10 @@ fun HomeScreen( Spacer(Modifier.height(12.dp)) if (uiState.goalList.goals.isEmpty()) { - EmptyGoalGuide(modifier = Modifier.weight(1f)) + EmptyGoalGuide( + modifier = Modifier.weight(1f), + text = stringResource(R.string.home_empty_goal_guide), + ) } else { GoalList( modifier = diff --git a/feature/main/src/main/java/com/twix/home/HomeViewModel.kt b/feature/main/src/main/java/com/twix/home/HomeViewModel.kt index db68185d..dec304d2 100644 --- a/feature/main/src/main/java/com/twix/home/HomeViewModel.kt +++ b/feature/main/src/main/java/com/twix/home/HomeViewModel.kt @@ -27,7 +27,7 @@ class HomeViewModel( fetchGoalList() viewModelScope.launch { - goalRefreshBus.events.collect { + goalRefreshBus.goalEvents.collect { fetchGoalList() } }