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