diff --git a/core/design-system/src/main/res/values/strings.xml b/core/design-system/src/main/res/values/strings.xml
index d1408359..391ceed0 100644
--- a/core/design-system/src/main/res/values/strings.xml
+++ b/core/design-system/src/main/res/values/strings.xml
@@ -11,6 +11,8 @@
편집
시작일
종료일
+ 반복 주기
+ 달성 횟수
미설정
취소
삭제
@@ -57,7 +59,6 @@
목표 직접 만들기
목표 수정하기
목표를 입력해 보세요.
- 반복 주기
시작 날짜
종료 날짜
종료 날짜 설정
@@ -81,8 +82,11 @@
아직 끝낸 목표가 없어요!
총 목표 %d번
%d번 완료
- 진행중
- 종료
+ 진행중
+ 종료
+
+
+ %1$s %2$d/%3$d
종료 날짜가 시작 날짜보다 이전입니다.
diff --git a/core/util/src/main/java/com/twix/util/bus/StatsRefreshBus.kt b/core/util/src/main/java/com/twix/util/bus/StatsRefreshBus.kt
new file mode 100644
index 00000000..7881c3b3
--- /dev/null
+++ b/core/util/src/main/java/com/twix/util/bus/StatsRefreshBus.kt
@@ -0,0 +1,21 @@
+package com.twix.util.bus
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+
+class StatsRefreshBus {
+ enum class Publisher {
+ InProgress,
+ End,
+ }
+
+ private val _events =
+ MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ )
+
+ val events: SharedFlow = _events
+
+ fun notifyChanged(publisher: Publisher) = _events.tryEmit(publisher)
+}
diff --git a/core/util/src/main/java/com/twix/util/di/UtilModule.kt b/core/util/src/main/java/com/twix/util/di/UtilModule.kt
index 56a5a0f1..92b3dae9 100644
--- a/core/util/src/main/java/com/twix/util/di/UtilModule.kt
+++ b/core/util/src/main/java/com/twix/util/di/UtilModule.kt
@@ -1,6 +1,7 @@
package com.twix.util.di
import com.twix.util.bus.GoalRefreshBus
+import com.twix.util.bus.StatsRefreshBus
import com.twix.util.bus.TaskCertificationRefreshBus
import org.koin.dsl.module
@@ -8,4 +9,5 @@ val utilModule =
module {
single { GoalRefreshBus() }
single { TaskCertificationRefreshBus() }
+ single { StatsRefreshBus() }
}
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 80070668..388e8c49 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
@@ -113,7 +113,7 @@ private fun RepeatTypeSettings(
Modifier
.padding(16.dp),
) {
- HeaderText(stringResource(R.string.header_repeat_type))
+ HeaderText(stringResource(R.string.word_repeat_type))
Spacer(Modifier.height(12.dp))
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 4ff3c63c..3f124cc6 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
@@ -357,7 +357,7 @@ private fun GoalSummaryItem(
?: stringResource(R.string.word_not_set)
GoalSummaryInfo(
- label = stringResource(R.string.header_repeat_type),
+ label = stringResource(R.string.word_repeat_type),
value = item.repeatCycle.label(),
)
GoalSummaryInfo(
diff --git a/feature/main/src/main/java/com/twix/stats/StatsViewModel.kt b/feature/main/src/main/java/com/twix/stats/StatsViewModel.kt
index 59951647..83b099d6 100644
--- a/feature/main/src/main/java/com/twix/stats/StatsViewModel.kt
+++ b/feature/main/src/main/java/com/twix/stats/StatsViewModel.kt
@@ -1,5 +1,6 @@
package com.twix.stats
+import androidx.lifecycle.viewModelScope
import com.twix.designsystem.R
import com.twix.designsystem.components.toast.model.ToastType
import com.twix.domain.repository.StatsRepository
@@ -7,17 +8,21 @@ import com.twix.stats.contract.StatsIntent
import com.twix.stats.contract.StatsSideEffect
import com.twix.stats.contract.StatsUiState
import com.twix.ui.base.BaseViewModel
+import com.twix.util.bus.StatsRefreshBus
import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
import java.time.LocalDate
class StatsViewModel(
private val statsRepository: StatsRepository,
+ private val eventBus: StatsRefreshBus,
) : BaseViewModel(StatsUiState()) {
private var inProgressStatsJob: Job? = null
init {
fetchInProgressStats(LocalDate.now())
fetchEndStats()
+ collectEventBus()
}
override suspend fun handleIntent(intent: StatsIntent) {
@@ -57,6 +62,17 @@ class StatsViewModel(
)
}
+ private fun collectEventBus() {
+ viewModelScope.launch {
+ eventBus.events.collect { publisher ->
+ when (publisher) {
+ StatsRefreshBus.Publisher.InProgress -> fetchInProgressStats(currentState.currentDate)
+ StatsRefreshBus.Publisher.End -> fetchEndStats()
+ }
+ }
+ }
+ }
+
private suspend fun showToast(
message: Int,
type: ToastType,
diff --git a/feature/main/src/main/java/com/twix/stats/model/StatsTabDestination.kt b/feature/main/src/main/java/com/twix/stats/model/StatsTabDestination.kt
index 294e20d7..fed665a7 100644
--- a/feature/main/src/main/java/com/twix/stats/model/StatsTabDestination.kt
+++ b/feature/main/src/main/java/com/twix/stats/model/StatsTabDestination.kt
@@ -7,6 +7,6 @@ enum class StatsTabDestination(
@field:StringRes
val label: Int,
) {
- IN_PROGRESS(R.string.stats_stamp_in_progress_tap_title),
- END(R.string.stats_stamp_end_tap_title),
+ IN_PROGRESS(R.string.stats_stamp_in_progress_tab_title),
+ END(R.string.stats_stamp_end_tab_title),
}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailRoute.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailRoute.kt
deleted file mode 100644
index 858730f8..00000000
--- a/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailRoute.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.twix.stats.detail
-
-import androidx.compose.runtime.Composable
-
-@Composable
-fun StatsDetailRoute(onBack: () -> Unit) {
-}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailScreen.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailScreen.kt
new file mode 100644
index 00000000..1dd78e1e
--- /dev/null
+++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailScreen.kt
@@ -0,0 +1,284 @@
+package com.twix.stats.detail
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+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.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.twix.designsystem.R
+import com.twix.designsystem.components.calendar.CalendarNavigator
+import com.twix.designsystem.components.dialog.CommonDialog
+import com.twix.designsystem.components.stats.StatsCalendar
+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.extension.toRes
+import com.twix.designsystem.theme.CommonColor
+import com.twix.designsystem.theme.GrayColor
+import com.twix.designsystem.theme.TwixTheme
+import com.twix.domain.model.enums.AppTextStyle
+import com.twix.domain.model.enums.GoalIconType
+import com.twix.stats.detail.component.StatsDetailTopbar
+import com.twix.stats.detail.component.SummaryContent
+import com.twix.stats.detail.contract.StatsDetailSideEffect
+import com.twix.stats.detail.contract.StatsDetailUiState
+import com.twix.stats.detail.preview.StatsDetailUiStatePreviewProvider
+import com.twix.ui.base.ObserveAsEvents
+import com.yapp.stats.detail.contract.StatsDetailIntent
+import org.koin.compose.koinInject
+import org.koin.compose.viewmodel.koinViewModel
+import java.time.LocalDate
+
+@Composable
+fun StatsDetailRoute(
+ onBack: () -> Unit,
+ toastManager: ToastManager = koinInject(),
+ viewModel: StatsDetailViewModel = koinViewModel(),
+) {
+ val context = LocalContext.current
+ val currentContext by rememberUpdatedState(context)
+
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ ObserveAsEvents(viewModel.sideEffect) { sideEffect ->
+ when (sideEffect) {
+ StatsDetailSideEffect.NavigateToBack -> onBack()
+ is StatsDetailSideEffect.ShowToast -> {
+ toastManager.tryShow(
+ ToastData(
+ message = currentContext.getString(sideEffect.message),
+ type = sideEffect.type,
+ ),
+ )
+ }
+ }
+ }
+
+ StatsDetailScreen(
+ uiState = uiState,
+ onBack = onBack,
+ onSelectDate = {},
+ onPreviousMonth = { viewModel.dispatch(StatsDetailIntent.PreviousMonth) },
+ onNextMonth = { viewModel.dispatch(StatsDetailIntent.NextMonth) },
+ onClickDeleteStats = { },
+ onClickPopupEdit = { },
+ onClickPopupEnd = { },
+ )
+}
+
+@Composable
+fun StatsDetailScreen(
+ uiState: StatsDetailUiState,
+ onBack: () -> Unit,
+ onSelectDate: (LocalDate) -> Unit,
+ onPreviousMonth: () -> Unit,
+ onNextMonth: () -> Unit,
+ onClickDeleteStats: () -> Unit,
+ onClickPopupEdit: () -> Unit,
+ onClickPopupEnd: () -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ var popupMenuVisibility by remember { mutableStateOf(false) }
+ var statsDeleteDialogVisibility by remember { mutableStateOf(false) }
+
+ Box {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(GrayColor.C050),
+ ) {
+ StatsDetailTopbar(
+ goalName = uiState.detail.goalName,
+ isInProgressStatsDetail = uiState.isInProgressStatsDetail,
+ popupMenuVisibility = popupMenuVisibility,
+ onBack = onBack,
+ onClickAction = {
+ if (uiState.isInProgressStatsDetail) {
+ popupMenuVisibility = true
+ } else {
+ statsDeleteDialogVisibility = true
+ }
+ },
+ onDismiss = { popupMenuVisibility = false },
+ onClickPopupEdit = onClickPopupEdit,
+ onClickPopupEnd = onClickPopupEnd,
+ onClickPopupDelete = {
+ popupMenuVisibility = false
+ statsDeleteDialogVisibility = true
+ },
+ )
+
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ ) {
+ Image(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_hug),
+ contentDescription = null,
+ modifier =
+ Modifier
+ .align(Alignment.TopStart)
+ .padding(start = 20.dp, top = 30.dp),
+ )
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(Modifier.height(32.dp))
+
+ CalendarNavigator(
+ currentDate = uiState.detail.monthDate,
+ onPreviousMonth = onPreviousMonth,
+ onNextMonth = onNextMonth,
+ hasPrevious = uiState.hasPrevious,
+ hasNext = uiState.hasNext,
+ )
+
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .background(CommonColor.White, shape = RoundedCornerShape(16.dp))
+ .border(
+ color = GrayColor.C500,
+ width = 1.dp,
+ shape = RoundedCornerShape(16.dp),
+ ).padding(horizontal = 12.dp)
+ .padding(top = 24.dp, bottom = 32.dp),
+ ) {
+ StatsCalendar(
+ uiModel = uiState.calendarUiModel,
+ onSelectedDate = onSelectDate,
+ )
+ }
+ }
+
+ Image(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_plane),
+ contentDescription = null,
+ modifier =
+ Modifier
+ .align(Alignment.TopEnd)
+ .padding(end = 27.dp, top = 30.dp),
+ )
+ }
+
+ Spacer(Modifier.height(44.dp))
+
+ SummaryContent(uiState.detail.statsSummary)
+ }
+
+ CommonDialog(
+ visible = statsDeleteDialogVisibility,
+ confirmText = stringResource(R.string.word_delete),
+ dismissText = stringResource(R.string.word_cancel),
+ onDismissRequest = { statsDeleteDialogVisibility = false },
+ onConfirm = onClickDeleteStats,
+ onDismiss = { statsDeleteDialogVisibility = false },
+ content = {
+ StatsDeleteDialogContent(
+ title =
+ stringResource(
+ R.string.dialog_delete_goal_title,
+ uiState.detail.goalName,
+ ),
+ content = stringResource(R.string.dialog_delete_goal_content),
+ icon = uiState.detail.goalIcon,
+ )
+ },
+ )
+ }
+}
+
+@Composable
+private fun StatsDeleteDialogContent(
+ title: String,
+ content: String,
+ icon: GoalIconType,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(icon.toRes()),
+ contentDescription = "emoji",
+ modifier =
+ Modifier
+ .size(60.dp),
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ AppText(
+ text = title,
+ style = AppTextStyle.T1,
+ color = GrayColor.C500,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ AppText(
+ text = content,
+ style = AppTextStyle.B2,
+ color = GrayColor.C500,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun StatsDetailScreenPreview(
+ @PreviewParameter(StatsDetailUiStatePreviewProvider::class)
+ uiState: StatsDetailUiState,
+) {
+ TwixTheme {
+ StatsDetailScreen(
+ uiState = uiState,
+ onBack = {},
+ onSelectDate = {},
+ onPreviousMonth = {},
+ onNextMonth = {},
+ onClickDeleteStats = {},
+ onClickPopupEdit = {},
+ onClickPopupEnd = {},
+ )
+ }
+}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailViewModel.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailViewModel.kt
new file mode 100644
index 00000000..316f29b2
--- /dev/null
+++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/StatsDetailViewModel.kt
@@ -0,0 +1,180 @@
+package com.twix.stats.detail
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.viewModelScope
+import com.twix.designsystem.R
+import com.twix.designsystem.components.stats.model.StatsCalendarUiModel
+import com.twix.designsystem.components.toast.model.ToastType
+import com.twix.domain.model.stats.detail.StatsDetail
+import com.twix.domain.repository.GoalRepository
+import com.twix.domain.repository.StatsRepository
+import com.twix.navigation.NavRoutes
+import com.twix.stats.detail.contract.StatsDetailSideEffect
+import com.twix.stats.detail.contract.StatsDetailUiState
+import com.twix.ui.base.BaseViewModel
+import com.twix.util.bus.StatsRefreshBus
+import com.yapp.stats.detail.contract.StatsDetailIntent
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.YearMonth
+
+@OptIn(FlowPreview::class)
+class StatsDetailViewModel(
+ private val statsRefreshBus: StatsRefreshBus,
+ private val goalRepository: GoalRepository,
+ private val statsRepository: StatsRepository,
+ savedStateHandle: SavedStateHandle,
+) : BaseViewModel(
+ StatsDetailUiState(),
+ ) {
+ private val argGoalId: Long =
+ requireNotNull(savedStateHandle[NavRoutes.StatsDetailRoute.ARG_GOAL_ID]) { GOAL_ID_NOT_FOUND }
+
+ private val argDate: String? = savedStateHandle.get(NavRoutes.StatsDetailRoute.ARG_DATE)
+
+ private val cache = mutableMapOf()
+
+ private val monthChangeFlow =
+ MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ init {
+ collectMonthChangeFlow()
+ reduceNavArguments()
+ fetchStatsDetail(argDate?.let { LocalDate.parse(it) })
+ }
+
+ private fun collectMonthChangeFlow() {
+ viewModelScope.launch {
+ monthChangeFlow
+ .distinctUntilChanged()
+ .debounce(DEBOUNCE_INTERVAL)
+ .collect { yearMonth -> fetchStatsDetail(yearMonth.atDay(1)) }
+ }
+ }
+
+ private fun reduceNavArguments() {
+ reduce {
+ copy(
+ goalId = argGoalId,
+ isInProgressStatsDetail = argDate != null,
+ )
+ }
+ }
+
+ private fun fetchStatsDetail(date: LocalDate?) {
+ val result = date?.let { checkCache(YearMonth.from(it)) }
+ if (result == true) return
+ launchResult(
+ block = { statsRepository.fetchStatsDetail(currentState.goalId, date) },
+ onSuccess = {
+ cache[YearMonth.from(it.monthDate)] = it
+ reduce {
+ copy(
+ detail = it,
+ calendarUiModel =
+ StatsCalendarUiModel.create(
+ currentDate = it.monthDate,
+ completedDate = it.completedDate,
+ ),
+ )
+ }
+ },
+ onError = {
+ reduce { copy(detail = detail.copy(monthDate = date ?: LocalDate.now())) }
+ showToast(R.string.toast_fetch_stats_failed, ToastType.ERROR)
+ },
+ )
+ }
+
+ private fun checkCache(yearMonth: YearMonth): Boolean {
+ cache[yearMonth]?.let {
+ reduce {
+ copy(
+ detail = it,
+ calendarUiModel =
+ StatsCalendarUiModel.create(
+ currentDate = it.monthDate,
+ completedDate = it.completedDate,
+ ),
+ )
+ }
+ return true
+ }
+ return false
+ }
+
+ override suspend fun handleIntent(intent: StatsDetailIntent) {
+ when (intent) {
+ StatsDetailIntent.PreviousMonth -> fetchPreviousMonth()
+ StatsDetailIntent.NextMonth -> fetchNextMonth()
+ StatsDetailIntent.GoalEnd -> {
+ // endGoal()
+ }
+
+ StatsDetailIntent.GoalDelete -> {
+ // deleteGoal()
+ }
+ }
+ }
+
+ private fun fetchPreviousMonth() {
+ val previousMonth = currentState.detail.monthDate.minusMonths(1)
+ reduce { copy(detail = detail.copy(monthDate = previousMonth)) }
+ monthChangeFlow.tryEmit(YearMonth.from(previousMonth))
+ }
+
+ private fun fetchNextMonth() {
+ val nextMonth = currentState.detail.monthDate.plusMonths(1)
+ reduce { copy(detail = detail.copy(monthDate = nextMonth)) }
+ monthChangeFlow.tryEmit(YearMonth.from(nextMonth))
+ }
+
+ private fun endGoal() {
+ launchResult(
+ block = { goalRepository.completeGoal(argGoalId) },
+ onSuccess = {
+ statsRefreshBus.notifyChanged(StatsRefreshBus.Publisher.InProgress)
+ tryEmitSideEffect(StatsDetailSideEffect.NavigateToBack)
+ },
+ onError = { showToast(R.string.toast_complete_goal_failed, ToastType.ERROR) },
+ )
+ }
+
+ private fun deleteGoal() {
+ launchResult(
+ block = { goalRepository.deleteGoal(argGoalId) },
+ onSuccess = {
+ val publisher =
+ when (currentState.isInProgressStatsDetail) {
+ true -> StatsRefreshBus.Publisher.InProgress
+ else -> StatsRefreshBus.Publisher.End
+ }
+ statsRefreshBus.notifyChanged(publisher)
+ tryEmitSideEffect(StatsDetailSideEffect.NavigateToBack)
+ },
+ onError = { showToast(R.string.toast_delete_goal_failed, ToastType.ERROR) },
+ )
+ }
+
+ private fun showToast(
+ message: Int,
+ type: ToastType,
+ ) {
+ viewModelScope.launch {
+ emitSideEffect(StatsDetailSideEffect.ShowToast(message, type))
+ }
+ }
+
+ companion object {
+ private const val GOAL_ID_NOT_FOUND = "Goal Id Argument Not Found"
+ private const val DEBOUNCE_INTERVAL = 300L
+ }
+}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/component/SummaryContent.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/component/SummaryContent.kt
new file mode 100644
index 00000000..b6f649fa
--- /dev/null
+++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/component/SummaryContent.kt
@@ -0,0 +1,178 @@
+package com.twix.stats.detail.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.twix.designsystem.R
+import com.twix.designsystem.components.text.AppText
+import com.twix.designsystem.extension.label
+import com.twix.designsystem.theme.GrayColor
+import com.twix.designsystem.theme.TwixTheme
+import com.twix.domain.model.enums.AppTextStyle
+import com.twix.domain.model.enums.RepeatCycle
+import com.twix.domain.model.stats.detail.StatsSummary
+import java.time.LocalDate
+
+@Composable
+fun SummaryContent(
+ statsSummary: StatsSummary,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.padding(horizontal = 20.dp),
+ ) {
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = GrayColor.C500,
+ )
+
+ Spacer(Modifier.height(20.dp))
+
+ Column(
+ modifier = Modifier.padding(horizontal = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ AppText(
+ text = stringResource(R.string.word_end_count),
+ style = AppTextStyle.C1,
+ color = GrayColor.C400,
+ modifier = Modifier.width(56.dp),
+ )
+ Spacer(Modifier.width(28.dp))
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_checked_you),
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ )
+
+ AppText(
+ text =
+ stringResource(
+ R.string.stats_complete_count,
+ statsSummary.myNickname,
+ statsSummary.myCompletedCount,
+ statsSummary.totalCount,
+ ),
+ style = AppTextStyle.B4,
+ color = GrayColor.C500,
+ )
+
+ VerticalDivider(
+ modifier = Modifier.height(15.dp),
+ thickness = 1.dp,
+ color = GrayColor.C200,
+ )
+
+ AppText(
+ text =
+ stringResource(
+ R.string.stats_complete_count,
+ statsSummary.partnerNickname,
+ statsSummary.partnerCompletedCount,
+ statsSummary.totalCount,
+ ),
+ style = AppTextStyle.B4,
+ color = GrayColor.C500,
+ )
+ }
+ }
+
+ SummaryRow(
+ label = stringResource(R.string.word_repeat_type),
+ value = statsSummary.repeatCycle.label(),
+ )
+ SummaryRow(
+ label = stringResource(R.string.word_start_date),
+ value =
+ stringResource(
+ R.string.date_year_month_day,
+ statsSummary.startDate.year,
+ statsSummary.startDate.monthValue,
+ statsSummary.startDate.dayOfMonth,
+ ),
+ )
+ SummaryRow(
+ label = stringResource(R.string.word_end_date),
+ value =
+ statsSummary.endDate?.let {
+ stringResource(
+ R.string.date_year_month_day,
+ it.year,
+ it.monthValue,
+ it.dayOfMonth,
+ )
+ } ?: stringResource(R.string.word_not_set),
+ )
+ }
+
+ Spacer(Modifier.height(20.dp))
+
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = GrayColor.C500,
+ )
+
+ Spacer(Modifier.height(52.dp))
+ }
+}
+
+@Composable
+private fun SummaryRow(
+ label: String,
+ value: String,
+) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ AppText(
+ text = label,
+ style = AppTextStyle.C1,
+ color = GrayColor.C400,
+ modifier = Modifier.width(56.dp),
+ )
+ Spacer(Modifier.width(28.dp))
+ AppText(
+ text = value,
+ style = AppTextStyle.B4,
+ color = GrayColor.C500,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SummaryContentPreview() {
+ TwixTheme {
+ SummaryContent(
+ statsSummary =
+ StatsSummary(
+ myNickname = "페토",
+ myCompletedCount = 10,
+ partnerNickname = "찬호",
+ partnerCompletedCount = 8,
+ totalCount = 20,
+ repeatCycle = RepeatCycle.DAILY,
+ startDate = LocalDate.now(),
+ endDate = LocalDate.now(),
+ ),
+ )
+ }
+}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailIntent.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailIntent.kt
new file mode 100644
index 00000000..212be41b
--- /dev/null
+++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailIntent.kt
@@ -0,0 +1,13 @@
+package com.yapp.stats.detail.contract
+
+import com.twix.ui.base.Intent
+
+sealed interface StatsDetailIntent : Intent {
+ data object PreviousMonth : StatsDetailIntent
+
+ data object NextMonth : StatsDetailIntent
+
+ data object GoalEnd : StatsDetailIntent
+
+ data object GoalDelete : StatsDetailIntent
+}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailUiState.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailUiState.kt
index 690808b4..7c2b0fae 100644
--- a/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailUiState.kt
+++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/contract/StatsDetailUiState.kt
@@ -5,13 +5,27 @@ import com.twix.designsystem.components.stats.model.StatsCalendarUiModel
import com.twix.domain.model.stats.detail.StatsDetail
import com.twix.ui.base.State
import java.time.LocalDate
+import java.time.YearMonth
@Immutable
data class StatsDetailUiState(
val goalId: Long = -1,
- val selectedDate: LocalDate? = null,
val detail: StatsDetail = StatsDetail.EMPTY,
+ val isInProgressStatsDetail: Boolean = true,
val calendarUiModel: StatsCalendarUiModel = StatsCalendarUiModel(),
) : State {
- val isInProgressStatsDetail get() = selectedDate != null
+ val hasNext: Boolean
+ get() {
+ val limitYm = YearMonth.from(detail.statsSummary.endDate ?: LocalDate.now())
+ val nextYm = YearMonth.from(detail.monthDate).plusMonths(1)
+
+ return nextYm <= limitYm
+ }
+
+ val hasPrevious: Boolean
+ get() {
+ val limitYm = YearMonth.from(detail.statsSummary.startDate)
+ val previousYm = YearMonth.from(detail.monthDate).minusMonths(1)
+ return previousYm >= limitYm
+ }
}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/di/StatsDetailModule.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/di/StatsDetailModule.kt
index 9564caf7..fd38dd87 100644
--- a/feature/stats/detail/src/main/java/com/twix/stats/detail/di/StatsDetailModule.kt
+++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/di/StatsDetailModule.kt
@@ -2,11 +2,14 @@ package com.twix.stats.detail.di
import com.twix.navigation.NavRoutes
import com.twix.navigation.base.NavGraphContributor
+import com.twix.stats.detail.StatsDetailViewModel
import com.twix.stats.detail.navigation.StatsDetailGraph
+import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.named
import org.koin.dsl.module
val statsDetailModule =
module {
single(named(NavRoutes.StatsDetailRoute.route)) { StatsDetailGraph }
+ viewModelOf(::StatsDetailViewModel)
}
diff --git a/feature/stats/detail/src/main/java/com/twix/stats/detail/preview/StatsDetailUiStatePreviewProvider.kt b/feature/stats/detail/src/main/java/com/twix/stats/detail/preview/StatsDetailUiStatePreviewProvider.kt
index 72cbff3b..fe094b09 100644
--- a/feature/stats/detail/src/main/java/com/twix/stats/detail/preview/StatsDetailUiStatePreviewProvider.kt
+++ b/feature/stats/detail/src/main/java/com/twix/stats/detail/preview/StatsDetailUiStatePreviewProvider.kt
@@ -61,13 +61,11 @@ class StatsDetailUiStatePreviewProvider : PreviewParameterProvider