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()
}
}