diff --git a/.gitignore b/.gitignore index 5f42ef503..66bd3c0e2 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,7 @@ lint/tmp/ /public/.well-known .firebase/ .claude +.codex +.agents +AGENTS.md +CLAUDE.md diff --git a/core/result/src/main/java/com/twix/result/Extension.kt b/core/result/src/main/java/com/twix/result/Extension.kt new file mode 100644 index 000000000..1990933b8 --- /dev/null +++ b/core/result/src/main/java/com/twix/result/Extension.kt @@ -0,0 +1,7 @@ +package com.twix.result + +fun AppResult.errorOrNull() = + when (this) { + is AppResult.Error -> error + is AppResult.Success -> null + } diff --git a/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt b/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt index 439c6164f..dba34eea8 100644 --- a/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt +++ b/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt @@ -86,13 +86,13 @@ abstract class BaseViewModel( /** * 서버 통신 메서드 호출 및 응답을 처리하는 헬퍼 메서드 * - * LoadableState를 구현한 경우 자동으로 isLoading과 error를 업데이트한다. + * DefaultLoadableState를 구현한 경우 자동으로 isLoading과 error를 업데이트한다. * 일반 State를 구현한 경우 onStart/onFinally로 화면별 로딩 상태를 직접 관리해야 한다. * * ## 에러 처리 가이드라인 * * ### 1. 데이터 로딩 (초기 로드, 화면 진입 시) - * - LoadableState 구현 시 자동으로 error 상태 업데이트 + * - DefaultLoadableState 구현 시 자동으로 error 상태 업데이트 * - `onError = null` 또는 추가 로직만 처리 * - 사용 예: 화면 진입 시 데이터 fetch, 리스트 초기 로드 * @@ -140,43 +140,58 @@ abstract class BaseViewModel( /** * 에러 초기화 - * LoadableState를 구현한 경우 자동으로 error를 null로 업데이트 + * DefaultLoadableState를 구현한 경우 자동으로 error를 null로 업데이트 */ private fun clearError() { - if (currentState is LoadableState) { - reduce { (this as LoadableState).copyLoadableState(error = null) as S } - } + reduceLoadableState { copyState(error = null) } } /** * 로딩 상태 시작 - * LoadableState를 구현한 경우 자동으로 isLoading을 true로 업데이트 + * DefaultLoadableState를 구현한 경우 자동으로 isLoading을 true로 업데이트 */ private fun startLoading() { loadingCount.update { it + 1 } - if (currentState is LoadableState && loadingCount.value == 1) { - reduce { (this as LoadableState).copyLoadableState(isLoading = true) as S } + if (loadingCount.value == 1) { + reduceLoadableState { copyState(isLoading = true) } } } /** * 로딩 상태 종료 - * LoadableState를 구현한 경우 자동으로 isLoading을 false로 업데이트 + * DefaultLoadableState를 구현한 경우 자동으로 isLoading을 false로 업데이트 */ private fun stopLoading() { loadingCount.update { maxOf(0, it - 1) } - if (currentState is LoadableState && loadingCount.value == 0) { - reduce { (this as LoadableState).copyLoadableState(isLoading = false) as S } + if (loadingCount.value == 0) { + reduceLoadableState { copyState(isLoading = false) } } } /** * 에러 업데이트 - * LoadableState를 구현한 경우 자동으로 error를 업데이트 + * DefaultLoadableState를 구현한 경우 자동으로 error를 업데이트 */ private fun updateError(error: AppError) { - if (currentState is LoadableState) { - reduce { (this as LoadableState).copyLoadableState(error = error) as S } + reduceLoadableState { copyState(error = error) } + } + + /** + * AppResult를 loading/error 상태 변경 없이 처리한다. + * + * best effort 요청처럼 DefaultLoadableState를 변경하지 않아야 하는 경우 사용한다. + */ + protected suspend fun handleResultWithoutLoadableStateUpdate( + result: AppResult, + onSuccess: (D) -> Unit = {}, + onError: (suspend (AppError) -> Unit)? = null, + ) { + when (result) { + is AppResult.Success -> onSuccess(result.data) + is AppResult.Error -> { + handleError(result.error) + onError?.invoke(result.error) + } } } @@ -191,7 +206,7 @@ abstract class BaseViewModel( when (result) { is AppResult.Success -> onSuccess(result.data) is AppResult.Error -> { - // 공통 처리: 로깅 및 LoadableState 에러 업데이트 + // 공통 처리: 로깅 및 DefaultLoadableState 에러 업데이트 handleError(result.error) updateError(result.error) // 메서드별 처리: 특정 화면만의 UX ex) 다이얼로그/토스트 @@ -200,6 +215,16 @@ abstract class BaseViewModel( } } + private inline fun reduceLoadableState(crossinline reducer: DefaultLoadableState.() -> DefaultLoadableState) { + if (currentState !is DefaultLoadableState) return + + reduce { + val loadableState = this as? DefaultLoadableState ?: return@reduce this + @Suppress("UNCHECKED_CAST") + loadableState.reducer() as S + } + } + /** * Throwable용 핸들러 ex) Intent 처리 중 발생한 예외 * */ diff --git a/core/ui/src/main/java/com/twix/ui/base/ContentLoadableState.kt b/core/ui/src/main/java/com/twix/ui/base/ContentLoadableState.kt new file mode 100644 index 000000000..2fb771c2c --- /dev/null +++ b/core/ui/src/main/java/com/twix/ui/base/ContentLoadableState.kt @@ -0,0 +1,29 @@ +package com.twix.ui.base + +/** + * 로딩/에러 상태와 함께 “콘텐츠가 한 번이라도 성공적으로 로드되었는지”를 표현하는 상태 계약. + * + * 초기 진입 시에는 전체 화면 로딩/에러를, 이후 재조회 시에는 기존 UI 위 overlay loading을 + * 보여줘야 하는 화면이 구현한다. + */ +interface ContentLoadableState : DefaultLoadableState { + val hasLoadedContent: Boolean + + /** + * 초기 화면 진입 단계에서 전체 UI 대신 loading Indicator를 보여줘야 하는지 여부. + */ + val showLoading: Boolean + get() = isLoading && !hasLoadedContent + + /** + * 초기 화면 진입 단계에서 전체 UI 대신 ErrorScreen를 보여줘야 하는지 여부. + */ + val showError: Boolean + get() = error != null && !hasLoadedContent + + /** + * 기존 UI 위에 loading Indicator를 보여줘야 하는지 여부. + */ + val showOverlayLoading: Boolean + get() = isLoading && hasLoadedContent +} diff --git a/core/ui/src/main/java/com/twix/ui/base/DefaultLoadableState.kt b/core/ui/src/main/java/com/twix/ui/base/DefaultLoadableState.kt new file mode 100644 index 000000000..3823f3a49 --- /dev/null +++ b/core/ui/src/main/java/com/twix/ui/base/DefaultLoadableState.kt @@ -0,0 +1,19 @@ +package com.twix.ui.base + +import com.twix.result.AppError + +/** + * 비동기 요청의 로딩/에러만 공통으로 관리하는 최소 상태 계약. + * + * 단순 액션 화면처럼 “기존 콘텐츠 유지 여부”를 별도로 판단할 필요가 없는 경우 + * 이 인터페이스만 구현하면 된다. + */ +interface DefaultLoadableState : State { + val isLoading: Boolean + val error: AppError? + + fun copyState( + isLoading: Boolean = this.isLoading, + error: AppError? = this.error, + ): DefaultLoadableState +} diff --git a/core/ui/src/main/java/com/twix/ui/base/LoadableState.kt b/core/ui/src/main/java/com/twix/ui/base/LoadableState.kt deleted file mode 100644 index 7e0feee90..000000000 --- a/core/ui/src/main/java/com/twix/ui/base/LoadableState.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.twix.ui.base - -import com.twix.result.AppError - -/** - * 로딩 상태와 에러를 포함하는 State 인터페이스 - * - * 로딩/에러 상태가 필요한 화면은 이 인터페이스를 구현하여 - * BaseViewModel의 launchResult가 자동으로 상태를 업데이트하도록 한다. - * - */ -interface LoadableState : State { - val isLoading: Boolean - val error: AppError? - - fun copyLoadableState( - isLoading: Boolean = this.isLoading, - error: AppError? = this.error, - ): LoadableState -} 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 f399856c1..556868b87 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 @@ -46,6 +46,8 @@ import com.twix.designsystem.components.bottomsheet.model.CommonBottomSheetConfi import com.twix.designsystem.components.button.AppButton import com.twix.designsystem.components.calendar.Calendar import com.twix.designsystem.components.dialog.CommonDialog +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -116,6 +118,7 @@ fun GoalEditorRoute( uiState = uiState, isEdit = goalId != -1L, onBack = navigateToBack, + onRetry = { viewModel.dispatch(GoalEditorIntent.InitGoal(goalId)) }, onCommitTitle = { viewModel.dispatch(GoalEditorIntent.SetTitle(it)) }, onSelectRepeatType = { viewModel.dispatch(GoalEditorIntent.SetRepeatType(it)) }, onCommitIcon = { viewModel.dispatch(GoalEditorIntent.SetIcon(it)) }, @@ -132,6 +135,7 @@ fun GoalEditorScreen( uiState: GoalEditorUiState, isEdit: Boolean = false, onBack: () -> Unit, + onRetry: () -> Unit, onCommitTitle: (String) -> Unit, onSelectRepeatType: (RepeatCycle) -> Unit, onCommitIcon: (GoalIconType) -> Unit, @@ -141,6 +145,16 @@ fun GoalEditorScreen( onToggleEndDateEnabled: (Boolean) -> Unit, onComplete: () -> Unit, ) { + if (isEdit && uiState.showLoading) { + TwixLoadingOverlay() + return + } + + if (isEdit && uiState.showError) { + ErrorScreen(onClickRetry = onRetry, onClickBack = onBack) + return + } + var showRepeatCountBottomSheet by remember { mutableStateOf(false) } var showCalendarBottomSheet by remember { mutableStateOf(false) } var showIconEditorDialog by remember { mutableStateOf(false) } @@ -506,6 +520,7 @@ private fun Preview() { GoalEditorScreen( uiState = uiState, onBack = {}, + onRetry = {}, onCommitTitle = {}, onSelectRepeatType = {}, onCommitEndDate = {}, 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 93aa46061..226c17476 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 @@ -102,6 +102,7 @@ class GoalEditorViewModel( startDate = goal.startDate.validStartDate(), endDate = (goal.endDate ?: LocalDate.now()).validEndDate(goal.startDate.validStartDate()), endDateEnabled = goal.endDate != null, + hasLoadedContent = true, ) } } @@ -192,12 +193,6 @@ class GoalEditorViewModel( onSuccess = { setGoal(it) }, onError = { initializedGoalId = null - emitSideEffect( - GoalEditorSideEffect.ShowToast( - R.string.toast_goal_fetch_failed, - ToastType.ERROR, - ), - ) }, ) } 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 e0d671a17..731d1a47a 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 @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.enums.RepeatCycle import com.twix.result.AppError -import com.twix.ui.base.LoadableState +import com.twix.ui.base.ContentLoadableState import java.time.LocalDate @Immutable @@ -17,9 +17,10 @@ data class GoalEditorUiState( val endDateEnabled: Boolean = false, val endDate: LocalDate = LocalDate.now(), val isSaving: Boolean = false, + override val hasLoadedContent: Boolean = false, override val isLoading: Boolean = false, override val error: AppError? = null, -) : LoadableState { +) : ContentLoadableState { val isSaveEnabled: Boolean get() = goalTitle.isNotBlank() @@ -29,8 +30,8 @@ data class GoalEditorUiState( val canSave: Boolean get() = isSaveEnabled && isEndDateValid && !isSaving - override fun copyLoadableState( + override fun copyState( isLoading: Boolean, error: AppError?, - ): LoadableState = copy(isLoading = isLoading, error = error) + ): ContentLoadableState = copy(isLoading = isLoading, error = error) } 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 86e8f6f70..f1d5b2de6 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 @@ -4,6 +4,8 @@ import com.twix.ui.base.Intent import java.time.LocalDate sealed interface GoalManageIntent : Intent { + data object Retry : GoalManageIntent + data class SetSelectedDate( val date: LocalDate, ) : 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 3dc1e1b69..7f31672ce 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 @@ -40,8 +40,10 @@ 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.error.ErrorScreen import com.twix.designsystem.components.goal.EmptyGoalGuide import com.twix.designsystem.components.goal.GoalCardFrame +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.popup.CommonPopup import com.twix.designsystem.components.popup.CommonPopupDivider import com.twix.designsystem.components.popup.CommonPopupItem @@ -91,6 +93,7 @@ fun GoalManageRoute( openedMenuGoalId = uiState.openedMenuGoalId, pendingIds = uiState.pendingGoalIds, onBack = popBackStack, + onRetry = { viewModel.dispatch(GoalManageIntent.Retry) }, onSelectDate = { viewModel.dispatch(GoalManageIntent.SetSelectedDate(it)) }, onPreviousWeek = { viewModel.dispatch(GoalManageIntent.PreviousWeek) }, onNextWeek = { viewModel.dispatch(GoalManageIntent.NextWeek) }, @@ -116,6 +119,7 @@ private fun GoalManageScreen( uiState: GoalManageUiState, openedMenuGoalId: Long?, onBack: () -> Unit, + onRetry: () -> Unit, onSelectDate: (LocalDate) -> Unit, onPreviousWeek: () -> Unit, onNextWeek: () -> Unit, @@ -143,105 +147,114 @@ private fun GoalManageScreen( if (deleteDialog != null) deleteDialogSnapshot = deleteDialog } - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(CommonColor.White), - ) { - CommonTopBar( - title = stringResource(R.string.word_edit), - left = { - Image( - painter = painterResource(R.drawable.ic_arrow3_left), - contentDescription = "back", - modifier = - Modifier - .padding(18.dp) - .size(24.dp) - .noRippleClickable(onClick = onBack), + when { + uiState.showLoading -> TwixLoadingOverlay() + uiState.showError -> ErrorScreen(onClickRetry = onRetry, onClickBack = onBack) + else -> + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(CommonColor.White), + ) { + CommonTopBar( + title = stringResource(R.string.word_edit), + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + }, ) - }, - ) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(4.dp)) - WeeklyCalendar( - selectedDate = uiState.selectedDate, - referenceDate = uiState.referenceDate, - onSelectDate = onSelectDate, - onPreviousWeek = onPreviousWeek, - onNextWeek = onNextWeek, - ) + WeeklyCalendar( + selectedDate = uiState.selectedDate, + referenceDate = uiState.referenceDate, + onSelectDate = onSelectDate, + onPreviousWeek = onPreviousWeek, + onNextWeek = onNextWeek, + ) - 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, - ) - } - } + 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 = endDialog != null, - confirmText = stringResource(R.string.action_complete_goal), - dismissText = stringResource(R.string.word_cancel), - onDismissRequest = onDismissEndDialog, - onConfirm = { - val id = endDialog?.goalId - onDismissEndDialog() - id?.let(onConfirmEnd) - }, - onDismiss = onDismissEndDialog, - content = { - 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 = endDialog != null, + confirmText = stringResource(R.string.action_complete_goal), + dismissText = stringResource(R.string.word_cancel), + onDismissRequest = onDismissEndDialog, + onConfirm = { + val id = endDialog?.goalId + onDismissEndDialog() + id?.let(onConfirmEnd) + }, + onDismiss = onDismissEndDialog, + content = { + 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 = deleteDialog != null, - confirmText = stringResource(R.string.word_delete), - dismissText = stringResource(R.string.word_cancel), - onDismissRequest = onDismissDeleteDialog, - onConfirm = { - val id = deleteDialog?.goalId - onDismissDeleteDialog() - id?.let(onConfirmDelete) - }, - onDismiss = onDismissDeleteDialog, - content = { - 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, + CommonDialog( + visible = deleteDialog != null, + confirmText = stringResource(R.string.word_delete), + dismissText = stringResource(R.string.word_cancel), + onDismissRequest = onDismissDeleteDialog, + onConfirm = { + val id = deleteDialog?.goalId + onDismissDeleteDialog() + id?.let(onConfirmDelete) + }, + onDismiss = onDismissDeleteDialog, + content = { + 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, + ) + }, ) - }, - ) + + if (uiState.showOverlayLoading) { + TwixLoadingOverlay() + } + } } } 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 68b348edf..977f603d0 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 @@ -22,13 +22,19 @@ class GoalManageViewModel( init { viewModelScope.launch { goalRefreshBus.goalSummariesEvents.collect { - fetchGoalSummaryList(currentState.selectedDate) + refreshGoalSummaryList() } } } override suspend fun handleIntent(intent: GoalManageIntent) { when (intent) { + GoalManageIntent.Retry -> + fetchGoalSummaryList( + date = currentState.selectedDate, + showInitialLoading = true, + showToastOnError = false, + ) is GoalManageIntent.EndGoal -> endGoal(intent.id) is GoalManageIntent.DeleteGoal -> deleteGoal(intent.id) is GoalManageIntent.SetSelectedDate -> setSelectedDate(intent.date) @@ -127,16 +133,44 @@ class GoalManageViewModel( if (currentState.selectedDate == date && currentState.isInitialized) return reduce { copy(selectedDate = date, isInitialized = true) } - fetchGoalSummaryList(date) + val shouldShowInitialLoading = !currentState.hasLoadedContent + fetchGoalSummaryList( + date = date, + showInitialLoading = shouldShowInitialLoading, + showToastOnError = !shouldShowInitialLoading, + ) } - private fun fetchGoalSummaryList(date: LocalDate) { + private fun refreshGoalSummaryList() { + if (!currentState.hasLoadedContent) return + + fetchGoalSummaryList( + date = currentState.selectedDate, + showInitialLoading = false, + showToastOnError = false, + ) + } + + private fun fetchGoalSummaryList( + date: LocalDate, + showInitialLoading: Boolean, + showToastOnError: Boolean, + ) { launchResult( block = { goalRepository.fetchGoalSummaryList(date.toString()) }, onSuccess = { - reduce { copy(goalSummaries = it) } + reduce { + copy( + goalSummaries = it, + hasLoadedContent = true, + ) + } + }, + onError = { + if (!showInitialLoading && showToastOnError) { + emitSideEffect(GoalManageSideEffect.ShowToast(R.string.toast_goal_fetch_failed, ToastType.ERROR)) + } }, - onError = { emitSideEffect(GoalManageSideEffect.ShowToast(R.string.toast_goal_fetch_failed, ToastType.ERROR)) }, ) } 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 0dd7bba0f..8dd160a38 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 @@ -3,7 +3,7 @@ package com.twix.goal_manage.model import androidx.compose.runtime.Immutable import com.twix.domain.model.goal.GoalSummary import com.twix.result.AppError -import com.twix.ui.base.LoadableState +import com.twix.ui.base.ContentLoadableState import java.time.LocalDate @Immutable @@ -16,11 +16,12 @@ data class GoalManageUiState( val openedMenuGoalId: Long? = null, // 팝업 현재 열린 goalId val endDialog: GoalDialogState? = null, val deleteDialog: GoalDialogState? = null, - override val isLoading: Boolean = false, + override val hasLoadedContent: Boolean = false, + override val isLoading: Boolean = true, override val error: AppError? = null, -) : LoadableState { - override fun copyLoadableState( +) : ContentLoadableState { + override fun copyState( isLoading: Boolean, error: AppError?, - ): LoadableState = copy(isLoading = isLoading, error = error) + ): ContentLoadableState = copy(isLoading = isLoading, error = error) } diff --git a/feature/login/src/main/java/com/twix/login/LoginScreen.kt b/feature/login/src/main/java/com/twix/login/LoginScreen.kt index 73ecdf7cf..338e59491 100644 --- a/feature/login/src/main/java/com/twix/login/LoginScreen.kt +++ b/feature/login/src/main/java/com/twix/login/LoginScreen.kt @@ -3,6 +3,7 @@ package com.twix.login import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -21,7 +22,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -50,6 +53,7 @@ fun LoginRoute( googleLoginProvider: GoogleLoginProvider = koinInject(), viewModel: LoginViewModel = koinViewModel(), ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val coroutineScope = rememberCoroutineScope() val context = LocalContext.current val currentContext by rememberUpdatedState(context) @@ -69,8 +73,12 @@ fun LoginRoute( } } - LoginScreen { type -> + LoginScreen( + isAuthenticating = uiState.showLoading, + ) { type -> coroutineScope.launch { + if (uiState.isLoading) return@launch + val result = when (type) { LoginType.KAKAO -> kakaoLoginProvider.login(currentContext) @@ -82,53 +90,70 @@ fun LoginRoute( } @Composable -private fun LoginScreen(onClickLogin: (LoginType) -> Unit) { +private fun LoginScreen( + isAuthenticating: Boolean, + onClickLogin: (LoginType) -> Unit, +) { val scrollState = rememberScrollState() - Column( + Box( modifier = Modifier .fillMaxSize() - .background(CommonColor.White) - .verticalScroll(scrollState), + .background(CommonColor.White), ) { - Spacer(Modifier.height(35.dp)) + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) { + Spacer(Modifier.height(35.dp)) - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_app_logo), - contentDescription = null, - modifier = Modifier.padding(start = 24.dp), - ) + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_app_logo), + contentDescription = null, + modifier = Modifier.padding(start = 24.dp), + ) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(24.dp)) - AppText( - text = stringResource(R.string.login_title_message), - style = AppTextStyle.H3, - color = GrayColor.C500, - modifier = Modifier.padding(start = 24.dp), - ) + AppText( + text = stringResource(R.string.login_title_message), + style = AppTextStyle.H3, + color = GrayColor.C500, + modifier = Modifier.padding(start = 24.dp), + ) - Spacer(Modifier.height(27.dp)) + Spacer(Modifier.height(27.dp)) - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_singing), - contentDescription = null, - ) + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_singing), + contentDescription = null, + ) - Column( - modifier = - Modifier - .padding(horizontal = 20.dp) - .padding(top = 29.dp, bottom = 27.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - LoginType.entries.forEach { type -> - LoginButton( - type = type, - onClickLogin = onClickLogin, - ) + Column( + modifier = + Modifier + .padding(horizontal = 20.dp) + .padding(top = 29.dp, bottom = 27.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + LoginType.entries.forEach { type -> + LoginButton( + type = type, + onClickLogin = { + if (!isAuthenticating) { + onClickLogin(it) + } + }, + ) + } } } + + if (isAuthenticating) { + TwixLoadingOverlay() + } } } @@ -136,6 +161,9 @@ private fun LoginScreen(onClickLogin: (LoginType) -> Unit) { @Composable private fun LoginScreenPreview() { TwixTheme { - LoginScreen(onClickLogin = {}) + LoginScreen( + isAuthenticating = false, + onClickLogin = {}, + ) } } diff --git a/feature/login/src/main/java/com/twix/login/LoginViewModel.kt b/feature/login/src/main/java/com/twix/login/LoginViewModel.kt index 584a33a28..3bdd21d5a 100644 --- a/feature/login/src/main/java/com/twix/login/LoginViewModel.kt +++ b/feature/login/src/main/java/com/twix/login/LoginViewModel.kt @@ -1,6 +1,5 @@ package com.twix.login -import androidx.lifecycle.viewModelScope import com.twix.designsystem.R import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.login.LoginResult @@ -11,7 +10,6 @@ import com.twix.login.contract.LoginIntent import com.twix.login.contract.LoginSideEffect import com.twix.login.contract.LoginUiState import com.twix.ui.base.BaseViewModel -import kotlinx.coroutines.launch class LoginViewModel( private val authRepository: AuthRepository, @@ -23,43 +21,29 @@ class LoginViewModel( } } - private fun login(result: LoginResult) { - viewModelScope.launch { - when (result) { - is LoginResult.Success -> { - authRepository.login(result.idToken, result.type) - checkOnboardingStatus() - } + private suspend fun login(result: LoginResult) { + if (currentState.isLoading) return - is LoginResult.Failure -> { - LoginSideEffect.ShowToast( - message = R.string.login_fail_message, - type = ToastType.ERROR, - ) - } - - LoginResult.Cancel -> Unit - } + when (result) { + is LoginResult.Success -> authenticate(result) + is LoginResult.Failure -> showToast(R.string.login_fail_message) + LoginResult.Cancel -> Unit } } + private fun authenticate(result: LoginResult.Success) { + launchResult( + block = { authRepository.login(result.idToken, result.type) }, + onSuccess = { checkOnboardingStatus() }, + onError = { showToast(R.string.login_fail_message) }, + ) + } + private fun checkOnboardingStatus() { launchResult( block = { onBoardingRepository.fetchOnboardingStatus() }, onSuccess = { onboardingStatus -> - viewModelScope.launch { - val sideEffect = - when (onboardingStatus) { - OnboardingStatus.COUPLE_CONNECTION, - OnboardingStatus.PROFILE_SETUP, - OnboardingStatus.ANNIVERSARY_SETUP, - -> LoginSideEffect.NavigateToOnBoarding(onboardingStatus) - - OnboardingStatus.COMPLETED -> LoginSideEffect.NavigateToHome - } - - emitSideEffect(sideEffect) - } + tryEmitSideEffect(onboardingStatus.toSideEffect()) }, onError = { emitSideEffect( @@ -71,4 +55,21 @@ class LoginViewModel( }, ) } + + private fun OnboardingStatus.toSideEffect(): LoginSideEffect { + if (this == OnboardingStatus.COMPLETED) { + return LoginSideEffect.NavigateToHome + } + + return LoginSideEffect.NavigateToOnBoarding(this) + } + + private suspend fun showToast(message: Int) { + emitSideEffect( + LoginSideEffect.ShowToast( + message = message, + type = ToastType.ERROR, + ), + ) + } } diff --git a/feature/login/src/main/java/com/twix/login/contract/LoginUiState.kt b/feature/login/src/main/java/com/twix/login/contract/LoginUiState.kt index 0780f02e7..05b49862a 100644 --- a/feature/login/src/main/java/com/twix/login/contract/LoginUiState.kt +++ b/feature/login/src/main/java/com/twix/login/contract/LoginUiState.kt @@ -1,15 +1,18 @@ package com.twix.login.contract import com.twix.result.AppError -import com.twix.ui.base.LoadableState +import com.twix.ui.base.DefaultLoadableState data class LoginUiState( val isLoggedIn: Boolean = false, override val isLoading: Boolean = false, override val error: AppError? = null, -) : LoadableState { - override fun copyLoadableState( +) : DefaultLoadableState { + val showLoading: Boolean + get() = isLoading + + override fun copyState( isLoading: Boolean, error: AppError?, - ): LoadableState = copy(isLoading = isLoading, error = error) + ): DefaultLoadableState = copy(isLoading = isLoading, error = error) } 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 3322c4ef5..f36035c0f 100644 --- a/feature/main/src/main/java/com/twix/home/HomeScreen.kt +++ b/feature/main/src/main/java/com/twix/home/HomeScreen.kt @@ -247,28 +247,38 @@ fun HomeScreen( ) } - uiState.showEmpty -> { - EmptyGoalGuide( - modifier = Modifier.weight(1f), - text = stringResource(R.string.home_empty_goal_guide), - ) - } - else -> { - GoalList( + Box( modifier = Modifier - .padding(horizontal = 20.dp) .weight(1f), - goals = uiState.goalList.goals, - selectedDate = uiState.selectedDate, - isRefreshing = uiState.isRefreshing, - onVerificationClick = onVerificationClick, - onEditClick = onEditClick, - onClickGoalCard = onClickCard, - onPokeGoal = onPokeGoal, - onRefresh = onRefresh, - ) + ) { + if (uiState.showEmpty) { + EmptyGoalGuide( + modifier = Modifier.fillMaxSize(), + text = stringResource(R.string.home_empty_goal_guide), + ) + } else { + GoalList( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + goals = uiState.goalList.goals, + selectedDate = uiState.selectedDate, + isRefreshing = uiState.isRefreshing, + onVerificationClick = onVerificationClick, + onEditClick = onEditClick, + onClickGoalCard = onClickCard, + onPokeGoal = onPokeGoal, + onRefresh = onRefresh, + ) + } + + if (uiState.showContentLoading) { + TwixLoadingOverlay() + } + } } } } 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 7741806e7..3173db98b 100644 --- a/feature/main/src/main/java/com/twix/home/HomeViewModel.kt +++ b/feature/main/src/main/java/com/twix/home/HomeViewModel.kt @@ -145,6 +145,7 @@ class HomeViewModel( * */ private fun fetchGoalList(isUserRefresh: Boolean = false) { val date = currentState.selectedDate.toString() + val shouldShowErrorScreen = !currentState.hasLoadedContent launchResult( onStart = { @@ -154,20 +155,25 @@ class HomeViewModel( reduce { copy(isRefreshing = false) } }, block = { goalRepository.fetchGoalList(date = date) }, - onSuccess = { goalList -> reduce { copy(goalList = goalList) } }, - onError = - if (isUserRefresh) { - { - emitSideEffect( - HomeSideEffect.ShowToast( - R.string.toast_goal_fetch_failed, - ToastType.ERROR, - ), - ) - } - } else { - null - }, + onSuccess = { goalList -> + reduce { + copy( + goalList = goalList, + hasLoadedContent = true, + ) + } + }, + onError = { + if (!shouldShowErrorScreen) { + reduce { copy(error = null) } + emitSideEffect( + HomeSideEffect.ShowToast( + R.string.toast_goal_fetch_failed, + ToastType.ERROR, + ), + ) + } + }, ) } } diff --git a/feature/main/src/main/java/com/twix/home/model/HomeUiState.kt b/feature/main/src/main/java/com/twix/home/model/HomeUiState.kt index ca3df3f17..bf8b201e8 100644 --- a/feature/main/src/main/java/com/twix/home/model/HomeUiState.kt +++ b/feature/main/src/main/java/com/twix/home/model/HomeUiState.kt @@ -3,7 +3,7 @@ package com.twix.home.model import androidx.compose.runtime.Immutable import com.twix.domain.model.goal.GoalList import com.twix.result.AppError -import com.twix.ui.base.LoadableState +import com.twix.ui.base.ContentLoadableState import java.time.LocalDate import java.time.YearMonth @@ -16,20 +16,19 @@ data class HomeUiState( val goalList: GoalList = GoalList(), val selectedGoalId: Long = -1, val isRefreshing: Boolean = false, // 당겨서 리프레시에 사용 + override val hasLoadedContent: Boolean = false, override val isLoading: Boolean = false, override val error: AppError? = null, -) : LoadableState { +) : ContentLoadableState { val monthYear: String get() = "${visibleDate.month.value}월 ${visibleDate.year}" - val showLoading get() = isLoading && goalList.goals.isEmpty() + val showContentLoading get() = showOverlayLoading - val showError get() = error != null + val showEmpty get() = hasLoadedContent && goalList.goals.isEmpty() && error == null - val showEmpty get() = goalList.goals.isEmpty() && !isLoading && error == null - - override fun copyLoadableState( + override fun copyState( isLoading: Boolean, error: AppError?, - ): LoadableState = copy(isLoading = isLoading, error = error) + ): ContentLoadableState = copy(isLoading = isLoading, error = error) } diff --git a/feature/main/src/main/java/com/twix/stats/StatsScreen.kt b/feature/main/src/main/java/com/twix/stats/StatsScreen.kt index 54a120ebb..f81b6f7a2 100644 --- a/feature/main/src/main/java/com/twix/stats/StatsScreen.kt +++ b/feature/main/src/main/java/com/twix/stats/StatsScreen.kt @@ -3,6 +3,7 @@ package com.twix.stats import androidx.compose.foundation.background import androidx.compose.foundation.interaction.Interaction import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -27,6 +28,8 @@ 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.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -76,6 +79,7 @@ fun StatsRoute( uiState = uiState, onClickInProgressPreviousMonth = { viewModel.dispatch(StatsIntent.PreviousMonth) }, onClickInProgressNextMonth = { viewModel.dispatch(StatsIntent.NextMonth) }, + onRetry = { viewModel.dispatch(StatsIntent.Retry) }, onClickStatsCard = { goalId, destination -> val currentDate = when (destination) { @@ -93,6 +97,7 @@ fun StatsScreen( uiState: StatsUiState, onClickInProgressPreviousMonth: () -> Unit, onClickInProgressNextMonth: () -> Unit, + onRetry: () -> Unit, onClickStatsCard: (Long, StatsTabDestination) -> Unit, ) { val pagerState = @@ -105,13 +110,39 @@ fun StatsScreen( ) { TitleTopBar(title = stringResource(R.string.stats_top_bar_title)) StatsTabRow(pagerState) - StatsTabPager( - uiState = uiState, - pagerState = pagerState, - onClickPreviousMonth = onClickInProgressPreviousMonth, - onClickNextMonth = onClickInProgressNextMonth, - onClickStatsCard = onClickStatsCard, - ) + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) { + when { + uiState.showLoading -> { + TwixLoadingOverlay() + } + + uiState.showError -> { + ErrorScreen( + onClickRetry = onRetry, + showBackButton = false, + ) + } + + else -> { + StatsTabPager( + uiState = uiState, + pagerState = pagerState, + onClickPreviousMonth = onClickInProgressPreviousMonth, + onClickNextMonth = onClickInProgressNextMonth, + onClickStatsCard = onClickStatsCard, + ) + + if (uiState.showContentLoading) { + TwixLoadingOverlay() + } + } + } + } } } @@ -220,6 +251,7 @@ fun StatsRoutePreview( uiState = uiState, onClickInProgressPreviousMonth = {}, onClickInProgressNextMonth = {}, + onRetry = {}, onClickStatsCard = { _, _ -> }, ) } 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 a67a297fb..a6f439087 100644 --- a/feature/main/src/main/java/com/twix/stats/StatsViewModel.kt +++ b/feature/main/src/main/java/com/twix/stats/StatsViewModel.kt @@ -6,6 +6,7 @@ import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.model.enums.StatsStatus import com.twix.domain.model.stats.Stats import com.twix.domain.repository.StatsRepository +import com.twix.result.AppResult import com.twix.stats.contract.StatsIntent import com.twix.stats.contract.StatsSideEffect import com.twix.stats.contract.StatsUiState @@ -45,8 +46,7 @@ class StatsViewModel( init { collectMonthChange() - fetchInProgressStats(YearMonth.from(currentState.currentDate)) - fetchCompletedStats() + fetchInitialStats() collectEventBus() } @@ -63,22 +63,30 @@ class StatsViewModel( override suspend fun handleIntent(intent: StatsIntent) { when (intent) { + StatsIntent.Retry -> fetchInitialStats() is StatsIntent.PreviousMonth -> fetchPreviousMonthStats() is StatsIntent.NextMonth -> fetchNextMonthStats() } } + private fun fetchInitialStats() { + val currentMonth = currentYearMonth() + fetchInProgressStats(currentMonth) + fetchCompletedStats() + } + private fun fetchInProgressStats( date: YearMonth, refresh: Boolean = false, ) { if (!refresh && applyCached(date)) return val requestId = ++latestInProgressRequestId + val shouldShowErrorScreen = !currentState.isLoadedInProgressStats launchResult( block = { statsRepository.fetchStats(date, StatsStatus.IN_PROGRESS) }, onSuccess = { stats -> handleFetchInProgressStatsSuccess(stats, date, requestId) }, - onError = { handleFetchInProgressStatsFail(requestId, date) }, + onError = { handleFetchInProgressStatsFail(requestId, date, shouldShowErrorScreen) }, ) } @@ -90,21 +98,29 @@ class StatsViewModel( inProgressStatsCache[date] = stats val isLatestRequest = requestId == latestInProgressRequestId - val isCurrentMonth = YearMonth.from(currentState.currentDate) == date + val isCurrentMonth = currentYearMonth() == date if (isLatestRequest && isCurrentMonth) { - reduce { copy(inProgressStats = stats) } + reduce { + copy( + inProgressStats = stats, + isLoadedInProgressStats = true, + ) + } } } private suspend fun handleFetchInProgressStatsFail( requestId: Long, date: YearMonth, + shouldShowErrorScreen: Boolean, ) { val isLatestRequest = requestId == latestInProgressRequestId - val isCurrentMonth = YearMonth.from(currentState.currentDate) == date - if (isLatestRequest && isCurrentMonth) { - showToast(R.string.toast_fetch_stats_failed, ToastType.ERROR) - } + val isCurrentMonth = currentYearMonth() == date + if (!isLatestRequest || !isCurrentMonth) return + if (shouldShowErrorScreen) return + + reduce { copy(error = null) } + showToast(R.string.toast_fetch_stats_failed, ToastType.ERROR) } private fun fetchPreviousMonthStats() { @@ -124,18 +140,33 @@ class StatsViewModel( } private fun fetchCompletedStats() { + val shouldShowErrorScreen = !currentState.isLoadedCompletedStats + launchResult( - block = { - statsRepository.fetchStats( - YearMonth.from(currentState.currentDate), - StatsStatus.COMPLETED, - ) - }, - onSuccess = { reduce { copy(completedStats = it.statsGoals) } }, - onError = { showToast(R.string.toast_fetch_stats_failed, ToastType.ERROR) }, + block = ::fetchCompletedStatsData, + onSuccess = ::handleFetchCompletedStatsSuccess, + onError = { handleFetchCompletedStatsFail(shouldShowErrorScreen) }, ) } + private suspend fun fetchCompletedStatsData(): AppResult = statsRepository.fetchStats(currentYearMonth(), StatsStatus.COMPLETED) + + private fun handleFetchCompletedStatsSuccess(stats: Stats) { + reduce { + copy( + completedStats = stats.statsGoals, + isLoadedCompletedStats = true, + ) + } + } + + private suspend fun handleFetchCompletedStatsFail(shouldShowErrorScreen: Boolean) { + if (shouldShowErrorScreen) return + + reduce { copy(error = null) } + showToast(R.string.toast_fetch_stats_failed, ToastType.ERROR) + } + private fun collectEventBus() { viewModelScope.launch { eventBus.events.collect { publisher -> @@ -152,16 +183,23 @@ class StatsViewModel( } private fun refreshInProgressStats() { - inProgressStatsCache.remove(YearMonth.from(currentState.currentDate)) + inProgressStatsCache.remove(currentYearMonth()) fetchInProgressStats( - YearMonth.from(currentState.currentDate), + currentYearMonth(), refresh = true, ) } + private fun currentYearMonth(): YearMonth = YearMonth.from(currentState.currentDate) + private fun applyCached(yearMonth: YearMonth): Boolean { val cached = inProgressStatsCache[yearMonth] ?: return false - reduce { copy(inProgressStats = cached) } + reduce { + copy( + inProgressStats = cached, + isLoadedInProgressStats = true, + ) + } return true } diff --git a/feature/main/src/main/java/com/twix/stats/contract/StatsIntent.kt b/feature/main/src/main/java/com/twix/stats/contract/StatsIntent.kt index b9d9bcc60..0ce175ec6 100644 --- a/feature/main/src/main/java/com/twix/stats/contract/StatsIntent.kt +++ b/feature/main/src/main/java/com/twix/stats/contract/StatsIntent.kt @@ -3,6 +3,8 @@ package com.twix.stats.contract import com.twix.ui.base.Intent sealed interface StatsIntent : Intent { + data object Retry : StatsIntent + data object PreviousMonth : StatsIntent data object NextMonth : StatsIntent diff --git a/feature/main/src/main/java/com/twix/stats/contract/StatsUiState.kt b/feature/main/src/main/java/com/twix/stats/contract/StatsUiState.kt index 708f5f846..e96dc9ad6 100644 --- a/feature/main/src/main/java/com/twix/stats/contract/StatsUiState.kt +++ b/feature/main/src/main/java/com/twix/stats/contract/StatsUiState.kt @@ -3,7 +3,8 @@ package com.twix.stats.contract import androidx.compose.runtime.Immutable import com.twix.domain.model.stats.Stats import com.twix.domain.model.stats.StatsGoal -import com.twix.ui.base.State +import com.twix.result.AppError +import com.twix.ui.base.ContentLoadableState import java.time.LocalDate @Immutable @@ -11,4 +12,18 @@ data class StatsUiState( val currentDate: LocalDate = LocalDate.now(), val inProgressStats: Stats = Stats.EMPTY, val completedStats: List = emptyList(), -) : State + val isLoadedInProgressStats: Boolean = false, + val isLoadedCompletedStats: Boolean = false, + override val isLoading: Boolean = false, + override val error: AppError? = null, +) : ContentLoadableState { + override val hasLoadedContent + get() = isLoadedInProgressStats && isLoadedCompletedStats + + val showContentLoading get() = showOverlayLoading + + override fun copyState( + isLoading: Boolean, + error: AppError?, + ): ContentLoadableState = copy(isLoading = isLoading, error = error) +} diff --git a/feature/main/src/main/java/com/twix/stats/preview/StatsUiStatePreviewProvider.kt b/feature/main/src/main/java/com/twix/stats/preview/StatsUiStatePreviewProvider.kt index 3f1bfdefb..334839d44 100644 --- a/feature/main/src/main/java/com/twix/stats/preview/StatsUiStatePreviewProvider.kt +++ b/feature/main/src/main/java/com/twix/stats/preview/StatsUiStatePreviewProvider.kt @@ -7,6 +7,7 @@ import com.twix.domain.model.enums.StampType import com.twix.domain.model.stats.ParticipantStats import com.twix.domain.model.stats.Stats import com.twix.domain.model.stats.StatsGoal +import com.twix.result.AppError import com.twix.stats.contract.StatsUiState import java.time.LocalDate @@ -15,6 +16,8 @@ class StatsUiStatePreviewProvider : PreviewParameterProvider { sequenceOf( defaultState(), emptyState(), + loadingState(), + errorState(), ) private fun defaultState() = @@ -54,6 +57,8 @@ class StatsUiStatePreviewProvider : PreviewParameterProvider { ), ), completedStats = emptyList(), + isLoadedInProgressStats = true, + isLoadedCompletedStats = true, ) private fun emptyState() = @@ -64,5 +69,17 @@ class StatsUiStatePreviewProvider : PreviewParameterProvider { statsGoals = emptyList(), ), completedStats = emptyList(), + isLoadedInProgressStats = true, + isLoadedCompletedStats = true, + ) + + private fun loadingState() = + StatsUiState( + isLoading = true, + ) + + private fun errorState() = + StatsUiState( + error = AppError.Network(), ) } diff --git a/feature/notification/src/main/java/com/twix/notification/NotificationScreen.kt b/feature/notification/src/main/java/com/twix/notification/NotificationScreen.kt index 93ddc90f5..32d55d4e8 100644 --- a/feature/notification/src/main/java/com/twix/notification/NotificationScreen.kt +++ b/feature/notification/src/main/java/com/twix/notification/NotificationScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -71,6 +73,7 @@ fun NotificationRoute( NotificationScreen( uiState = uiState, onBack = popBackStack, + onRetry = { viewModel.dispatch(NotificationIntent.Retry) }, onNotificationClick = { viewModel.dispatch(NotificationIntent.NotificationClicked(it)) }, onNextPage = { viewModel.dispatch(NotificationIntent.FetchNextPage) }, ) @@ -80,6 +83,7 @@ fun NotificationRoute( private fun NotificationScreen( uiState: NotificationUiState, onBack: () -> Unit, + onRetry: () -> Unit, onNotificationClick: (Long) -> Unit, onNextPage: () -> Unit, ) { @@ -96,53 +100,59 @@ private fun NotificationScreen( }.distinctUntilChanged() .filter { it } .collect { - if (uiState.hasNext && !uiState.isLoading) { + if (uiState.canLoadNextPage) { onNextPage() } } } - Column( - modifier = - Modifier - .fillMaxSize(), - ) { - CommonTopBar( - title = stringResource(R.string.word_notification), - left = { - Image( - painter = painterResource(R.drawable.ic_arrow3_left), - contentDescription = "back", - modifier = - Modifier - .padding(18.dp) - .size(24.dp) - .noRippleClickable(onClick = onBack), + when { + uiState.showLoading -> TwixLoadingOverlay() + uiState.showError -> ErrorScreen(onClickRetry = onRetry, onClickBack = onBack) + else -> { + Column( + modifier = + Modifier + .fillMaxSize(), + ) { + CommonTopBar( + title = stringResource(R.string.word_notification), + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + }, ) - }, - ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - AppText( - text = stringResource(R.string.notification_recent_14_days), - style = AppTextStyle.T1, - color = GrayColor.C500, - modifier = - Modifier.padding(start = 20.dp), - ) + AppText( + text = stringResource(R.string.notification_recent_14_days), + style = AppTextStyle.T1, + color = GrayColor.C500, + modifier = + Modifier.padding(start = 20.dp), + ) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(4.dp)) - NotificationList( - modifier = - Modifier - .weight(1f) - .fillMaxWidth(), - notificationsList = uiState.notificationList, - listState = listState, - onNotificationClick = onNotificationClick, - ) + NotificationList( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + notificationsList = uiState.notificationList, + listState = listState, + onNotificationClick = onNotificationClick, + ) + } + } } } @@ -153,6 +163,8 @@ private fun Preview() { NotificationScreen( uiState = NotificationUiState( + hasLoadedContent = true, + isLoading = false, notificationList = listOf( Notification( @@ -176,6 +188,7 @@ private fun Preview() { ), ), onBack = {}, + onRetry = {}, onNotificationClick = {}, onNextPage = {}, ) diff --git a/feature/notification/src/main/java/com/twix/notification/NotificationViewModel.kt b/feature/notification/src/main/java/com/twix/notification/NotificationViewModel.kt index 212bd4898..6ec2aa31f 100644 --- a/feature/notification/src/main/java/com/twix/notification/NotificationViewModel.kt +++ b/feature/notification/src/main/java/com/twix/notification/NotificationViewModel.kt @@ -1,5 +1,6 @@ package com.twix.notification +import androidx.lifecycle.viewModelScope import com.twix.designsystem.R import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.model.notification.Notification @@ -10,6 +11,7 @@ import com.twix.notification.contract.NotificationUiState import com.twix.notification.deeplink.NotificationDeepLink import com.twix.notification.deeplink.NotificationDeepLinkParser import com.twix.ui.base.BaseViewModel +import kotlinx.coroutines.launch class NotificationViewModel( private val notificationDeepLinkParser: NotificationDeepLinkParser, @@ -23,6 +25,7 @@ class NotificationViewModel( override suspend fun handleIntent(intent: NotificationIntent) { when (intent) { + NotificationIntent.Retry -> fetchInitialNotificationList() NotificationIntent.FetchNextPage -> fetchNextNotificationList() is NotificationIntent.NotificationClicked -> handleNotificationClick(intent.notificationId) } @@ -35,7 +38,13 @@ class NotificationViewModel( block = { notificationRepository.fetchNotifications() }, onSuccess = { markAllNotificationAsRead() - reduce { copy(notificationList = it.notifications, hasNext = it.hasNext) } + reduce { + copy( + notificationList = it.notifications, + hasNext = it.hasNext, + hasLoadedContent = true, + ) + } }, ) } @@ -66,10 +75,9 @@ class NotificationViewModel( } private fun markAllNotificationAsRead() { - launchResult( - block = { notificationRepository.markAllNotificationsAsRead() }, - onSuccess = {}, - ) + viewModelScope.launch { + handleResultWithoutLoadableStateUpdate(notificationRepository.markAllNotificationsAsRead()) + } } private suspend fun handleNotificationClick(id: Long) { @@ -94,7 +102,7 @@ class NotificationViewModel( } // 알림 읽음 처리는 best effort가 정책이므로 에러 처리는 생략 - private fun markNotificationAsRead(notification: Notification) { + private suspend fun markNotificationAsRead(notification: Notification) { reduce { copy( notificationList = @@ -104,9 +112,6 @@ class NotificationViewModel( ) } - launchResult( - block = { notificationRepository.markNotificationAsRead(notification.id) }, - onSuccess = {}, - ) + handleResultWithoutLoadableStateUpdate(notificationRepository.markNotificationAsRead(notification.id)) } } diff --git a/feature/notification/src/main/java/com/twix/notification/contract/NotificationIntent.kt b/feature/notification/src/main/java/com/twix/notification/contract/NotificationIntent.kt index 0642b9dff..4288ad9d9 100644 --- a/feature/notification/src/main/java/com/twix/notification/contract/NotificationIntent.kt +++ b/feature/notification/src/main/java/com/twix/notification/contract/NotificationIntent.kt @@ -3,6 +3,8 @@ package com.twix.notification.contract import com.twix.ui.base.Intent sealed interface NotificationIntent : Intent { + data object Retry : NotificationIntent + data object FetchNextPage : NotificationIntent data class NotificationClicked( diff --git a/feature/notification/src/main/java/com/twix/notification/contract/NotificationUiState.kt b/feature/notification/src/main/java/com/twix/notification/contract/NotificationUiState.kt index 5f0e5fa29..c812a4a3f 100644 --- a/feature/notification/src/main/java/com/twix/notification/contract/NotificationUiState.kt +++ b/feature/notification/src/main/java/com/twix/notification/contract/NotificationUiState.kt @@ -3,17 +3,21 @@ package com.twix.notification.contract import androidx.compose.runtime.Immutable import com.twix.domain.model.notification.Notification import com.twix.result.AppError -import com.twix.ui.base.LoadableState +import com.twix.ui.base.ContentLoadableState @Immutable data class NotificationUiState( val notificationList: List = emptyList(), val hasNext: Boolean = true, + override val hasLoadedContent: Boolean = false, override val isLoading: Boolean = false, override val error: AppError? = null, -) : LoadableState { - override fun copyLoadableState( +) : ContentLoadableState { + val canLoadNextPage: Boolean + get() = hasNext && !isLoading + + override fun copyState( isLoading: Boolean, error: AppError?, - ): LoadableState = copy(isLoading = isLoading, error = error) + ): ContentLoadableState = copy(isLoading = isLoading, error = error) } diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/OnBoardingViewModel.kt b/feature/onboarding/src/main/java/com/twix/onboarding/OnBoardingViewModel.kt index c27b82fc7..848ce44c3 100644 --- a/feature/onboarding/src/main/java/com/twix/onboarding/OnBoardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/twix/onboarding/OnBoardingViewModel.kt @@ -8,6 +8,7 @@ import com.twix.domain.model.invitecode.InviteCode import com.twix.domain.repository.NotificationRepository import com.twix.domain.repository.OnBoardingRepository import com.twix.onboarding.contract.OnBoardingIntent +import com.twix.onboarding.contract.OnBoardingLoadingAction import com.twix.onboarding.contract.OnBoardingSideEffect import com.twix.onboarding.contract.OnBoardingUiState import com.twix.result.AppError @@ -25,28 +26,30 @@ class OnBoardingViewModel( ) : BaseViewModel(OnBoardingUiState()) { private var pollingJob: Job? = null private var connectCoupleJob: Job? = null + private var inviteCodeInitializationJob: Job? = null init { fetchMyInviteCode() } private fun fetchMyInviteCode() { - launchResult( - block = { onBoardingRepository.fetchInviteCode() }, - onSuccess = { fetchedInviteCode -> - reduce { - copy( - inviteCode = - inviteCode.copy( - myInviteCode = fetchedInviteCode.value, - ), - ) - } - }, - onError = { - showToast(R.string.onboarding_couple_fetch_my_invite_code_fail, ToastType.ERROR) - }, - ) + if (inviteCodeInitializationJob?.isActive == true) return + + inviteCodeInitializationJob = + launchResult( + block = { onBoardingRepository.fetchInviteCode() }, + onSuccess = { fetchedInviteCode -> + reduce { + copy( + inviteCode = + inviteCode.copy( + myInviteCode = fetchedInviteCode.value, + ), + hasLoadedContent = true, + ) + } + }, + ) } override suspend fun handleIntent(intent: OnBoardingIntent) { @@ -58,6 +61,7 @@ class OnBoardingViewModel( emitSideEffect(OnBoardingSideEffect.InviteCode.CopyInviteCode(currentState.inviteCode.myInviteCode)) OnBoardingIntent.ShareInviteLink -> emitSideEffect(OnBoardingSideEffect.InviteCode.ShareInviteLink(currentState.inviteCode.myInviteCode)) + OnBoardingIntent.RetryFetchInviteCode -> fetchMyInviteCode() // 초대 코드 화면 OnBoardingIntent.StartPollingStatus -> startPolling() @@ -137,10 +141,13 @@ class OnBoardingViewModel( private fun connectCouple() { val currentUiState = currentState.inviteCode if (!currentState.inviteCode.isValid) return + if (currentState.loadingAction != null) return if (connectCoupleJob?.isActive == true) return connectCoupleJob = launchResult( + onStart = { startLoadingAction(OnBoardingLoadingAction.CONNECT_COUPLE) }, + onFinally = { clearLoadingAction(OnBoardingLoadingAction.CONNECT_COUPLE) }, block = { onBoardingRepository.coupleConnection(currentUiState.partnerInviteCode) }, onSuccess = { stopPolling() @@ -187,7 +194,11 @@ class OnBoardingViewModel( } private fun profileSetup() { + if (currentState.loadingAction != null) return + launchResult( + onStart = { startLoadingAction(OnBoardingLoadingAction.SUBMIT_PROFILE) }, + onFinally = { clearLoadingAction(OnBoardingLoadingAction.SUBMIT_PROFILE) }, block = { onBoardingRepository.profileSetup(currentState.profile.nickname) }, onSuccess = { fetchOnboardingStatus() }, onError = { showToast(R.string.onboarding_profile_setup_fail, ToastType.ERROR) }, @@ -222,7 +233,11 @@ class OnBoardingViewModel( } private fun anniversarySetup() { + if (currentState.loadingAction != null) return + launchResult( + onStart = { startLoadingAction(OnBoardingLoadingAction.SUBMIT_DDAY) }, + onFinally = { clearLoadingAction(OnBoardingLoadingAction.SUBMIT_DDAY) }, block = { onBoardingRepository.anniversarySetup(currentState.dDay.anniversaryDate.toString()) }, onSuccess = { tryEmitSideEffect(OnBoardingSideEffect.DdaySetting.NavigateToHome) }, onError = { @@ -236,7 +251,11 @@ class OnBoardingViewModel( isMarketingEnabled: Boolean, isNightMarketingEnabled: Boolean, ) { + if (currentState.loadingAction != null) return + launchResult( + onStart = { startLoadingAction(OnBoardingLoadingAction.SUBMIT_MARKETING_CONSENT) }, + onFinally = { clearLoadingAction(OnBoardingLoadingAction.SUBMIT_MARKETING_CONSENT) }, block = { notificationRepository.initNotificationSettings( isPushEnabled, @@ -248,6 +267,17 @@ class OnBoardingViewModel( ) } + private fun startLoadingAction(action: OnBoardingLoadingAction) { + reduce { copy(loadingAction = action) } + } + + private fun clearLoadingAction(expectedAction: OnBoardingLoadingAction) { + reduce { + if (loadingAction != expectedAction) return@reduce this + copy(loadingAction = null) + } + } + private suspend fun showToast( message: Int, type: ToastType, diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingIntent.kt b/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingIntent.kt index 72fcb4aa8..f0c99038b 100644 --- a/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingIntent.kt +++ b/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingIntent.kt @@ -26,6 +26,8 @@ sealed interface OnBoardingIntent : Intent { data object ConnectCouple : OnBoardingIntent + data object RetryFetchInviteCode : OnBoardingIntent + data class SelectDate( val value: LocalDate, ) : OnBoardingIntent diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingLoadingAction.kt b/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingLoadingAction.kt new file mode 100644 index 000000000..c79e384e8 --- /dev/null +++ b/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingLoadingAction.kt @@ -0,0 +1,8 @@ +package com.twix.onboarding.contract + +enum class OnBoardingLoadingAction { + CONNECT_COUPLE, + SUBMIT_PROFILE, + SUBMIT_DDAY, + SUBMIT_MARKETING_CONSENT, +} diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingUiState.kt b/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingUiState.kt index 0bc02b2b9..a27680542 100644 --- a/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingUiState.kt +++ b/feature/onboarding/src/main/java/com/twix/onboarding/contract/OnBoardingUiState.kt @@ -5,21 +5,39 @@ import com.twix.onboarding.dday.DdayUiModel import com.twix.onboarding.invite.InviteCodeUiModel import com.twix.onboarding.profile.ProfileUiModel import com.twix.result.AppError -import com.twix.ui.base.LoadableState +import com.twix.ui.base.ContentLoadableState @Immutable data class OnBoardingUiState( val profile: ProfileUiModel = ProfileUiModel(), val inviteCode: InviteCodeUiModel = InviteCodeUiModel(), val dDay: DdayUiModel = DdayUiModel(), - override val isLoading: Boolean = false, + val loadingAction: OnBoardingLoadingAction? = null, + override val hasLoadedContent: Boolean = false, + override val isLoading: Boolean = true, override val error: AppError? = null, -) : LoadableState { +) : ContentLoadableState { val isValidNickName: Boolean get() = profile.isValid - override fun copyLoadableState( + val isConnectingCouple: Boolean + get() = isLoading && loadingAction == OnBoardingLoadingAction.CONNECT_COUPLE + + val isSubmittingProfile: Boolean + get() = isLoading && loadingAction == OnBoardingLoadingAction.SUBMIT_PROFILE + + val isSubmittingDday: Boolean + get() = isLoading && loadingAction == OnBoardingLoadingAction.SUBMIT_DDAY + + val isSubmittingMarketingConsent: Boolean + get() = isLoading && loadingAction == OnBoardingLoadingAction.SUBMIT_MARKETING_CONSENT + + override fun copyState( isLoading: Boolean, error: AppError?, - ): LoadableState = copy(isLoading = isLoading, error = error) + ): ContentLoadableState = + copy( + isLoading = isLoading, + error = error, + ) } diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/couple/CoupleConnectScreen.kt b/feature/onboarding/src/main/java/com/twix/onboarding/couple/CoupleConnectScreen.kt index eb6b8fd05..48ceaa4eb 100644 --- a/feature/onboarding/src/main/java/com/twix/onboarding/couple/CoupleConnectScreen.kt +++ b/feature/onboarding/src/main/java/com/twix/onboarding/couple/CoupleConnectScreen.kt @@ -36,10 +36,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R import com.twix.designsystem.components.bottomsheet.CommonBottomSheet import com.twix.designsystem.components.bottomsheet.model.CommonBottomSheetConfig import com.twix.designsystem.components.dialog.MarketingDialog +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -67,6 +70,7 @@ fun CoupleConnectRoute( navigateToNext: () -> Unit, navigateToBack: () -> Unit, ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showMarketingDialog by rememberSaveable { mutableStateOf(true) } var showRestoreSheet by rememberSaveable { mutableStateOf(false) } val context = LocalContext.current @@ -105,17 +109,27 @@ fun CoupleConnectRoute( } Box { - CoupleConnectScreen( - showRestoreSheet = showRestoreSheet, - onClickSend = { viewModel.dispatch(OnBoardingIntent.ShareInviteLink) }, - onClickConnect = navigateToNext, - onClickRestore = { showRestoreSheet = true }, - onDismissSheet = { showRestoreSheet = false }, - onClickBack = navigateToBack, - ) + when { + uiState.showLoading -> TwixLoadingOverlay() + uiState.showError -> + ErrorScreen( + onClickRetry = { viewModel.dispatch(OnBoardingIntent.RetryFetchInviteCode) }, + onClickBack = navigateToBack, + ) + else -> + CoupleConnectScreen( + showRestoreSheet = showRestoreSheet, + showLoadingOverlay = uiState.isSubmittingMarketingConsent, + onClickSend = { viewModel.dispatch(OnBoardingIntent.ShareInviteLink) }, + onClickConnect = navigateToNext, + onClickRestore = { showRestoreSheet = true }, + onDismissSheet = { showRestoreSheet = false }, + onClickBack = navigateToBack, + ) + } MarketingDialog( - visible = showMarketingDialog, + visible = showMarketingDialog && !uiState.showLoading && !uiState.showError, onConfirm = { marketing, nightMarketing -> showMarketingDialog = false val isPushEnabled = isNotificationPermissionGranted(context) @@ -134,6 +148,7 @@ fun CoupleConnectRoute( @Composable fun CoupleConnectScreen( showRestoreSheet: Boolean, + showLoadingOverlay: Boolean, onClickSend: () -> Unit, onClickConnect: () -> Unit, onClickRestore: () -> Unit, @@ -211,6 +226,10 @@ fun CoupleConnectScreen( onDismissRequest = onDismissSheet, content = { RestoreCoupleBottomSheetContent() }, ) + + if (showLoadingOverlay) { + TwixLoadingOverlay() + } } } @@ -234,6 +253,7 @@ private fun CoupleConnectScreenPreview() { TwixTheme { CoupleConnectScreen( showRestoreSheet = false, + showLoadingOverlay = false, onClickSend = {}, onClickConnect = {}, onClickRestore = {}, diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/dday/DdayRoute.kt b/feature/onboarding/src/main/java/com/twix/onboarding/dday/DdayRoute.kt index 50c9c139a..6fe6a1e83 100644 --- a/feature/onboarding/src/main/java/com/twix/onboarding/dday/DdayRoute.kt +++ b/feature/onboarding/src/main/java/com/twix/onboarding/dday/DdayRoute.kt @@ -26,6 +26,7 @@ import com.twix.designsystem.components.bottomsheet.CommonBottomSheet import com.twix.designsystem.components.bottomsheet.model.CommonBottomSheetConfig import com.twix.designsystem.components.button.AppButton import com.twix.designsystem.components.calendar.Calendar +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -72,6 +73,7 @@ fun DdayRoute( DdayScreen( uiModel = uiState.dDay, + showLoadingOverlay = uiState.isSubmittingDday, onCompleted = { viewModel.dispatch(OnBoardingIntent.SubmitDday) }, onClickBack = navigateToBack, onDateClick = { showCalendarBottomSheet = true }, @@ -87,6 +89,7 @@ fun DdayRoute( @Composable fun DdayScreen( uiModel: DdayUiModel, + showLoadingOverlay: Boolean, onCompleted: () -> Unit, onClickBack: () -> Unit, onDateClick: () -> Unit, @@ -145,6 +148,10 @@ fun DdayScreen( onComplete = onDateSelected, ) } + + if (showLoadingOverlay) { + TwixLoadingOverlay() + } } } @@ -157,6 +164,7 @@ fun DdayScreenPreview() { DdayUiModel( anniversaryDate = LocalDate.now(), ), + showLoadingOverlay = false, onClickBack = {}, onDateClick = {}, showCalendarBottomSheet = false, diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/invite/InviteCodeScreen.kt b/feature/onboarding/src/main/java/com/twix/onboarding/invite/InviteCodeScreen.kt index ac24b1cc6..53d56f5ee 100644 --- a/feature/onboarding/src/main/java/com/twix/onboarding/invite/InviteCodeScreen.kt +++ b/feature/onboarding/src/main/java/com/twix/onboarding/invite/InviteCodeScreen.kt @@ -50,6 +50,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R import com.twix.designsystem.components.button.AppButton +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -78,9 +80,14 @@ internal fun InviteCodeRoute( toastManager: ToastManager = koinInject(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var hasInjectedInitialInviteCode by remember(initialInviteCode) { mutableStateOf(false) } - LaunchedEffect(initialInviteCode) { + LaunchedEffect(initialInviteCode, uiState.showLoading, uiState.showError, hasInjectedInitialInviteCode) { + if (hasInjectedInitialInviteCode) return@LaunchedEffect + if (uiState.showLoading) return@LaunchedEffect + if (uiState.showError) return@LaunchedEffect if (!initialInviteCode.isNullOrBlank()) { + hasInjectedInitialInviteCode = true viewModel.dispatch(OnBoardingIntent.WriteInviteCode(initialInviteCode)) } } @@ -90,7 +97,11 @@ internal fun InviteCodeRoute( val currentContext by rememberUpdatedState(context) val clipboard = LocalClipboard.current - DisposableEffect(Unit) { + DisposableEffect(uiState.showLoading, uiState.showError) { + if (uiState.showLoading || uiState.showError) { + return@DisposableEffect onDispose {} + } + viewModel.dispatch(OnBoardingIntent.StartPollingStatus) onDispose { viewModel.dispatch(OnBoardingIntent.StopPollingStatus) @@ -138,20 +149,31 @@ internal fun InviteCodeRoute( } } - InviteCodeScreen( - uiModel = uiState.inviteCode, - keyboardState = keyboardState, - navigateToBack = navigateToBack, - onChangeInviteCode = { viewModel.dispatch(OnBoardingIntent.WriteInviteCode(it)) }, - onComplete = { viewModel.dispatch(OnBoardingIntent.ConnectCouple) }, - onCopyInviteCode = { viewModel.dispatch(OnBoardingIntent.CopyInviteCode) }, - ) + when { + uiState.showLoading -> TwixLoadingOverlay() + uiState.showError -> + ErrorScreen( + onClickRetry = { viewModel.dispatch(OnBoardingIntent.RetryFetchInviteCode) }, + onClickBack = navigateToBack, + ) + else -> + InviteCodeScreen( + uiModel = uiState.inviteCode, + keyboardState = keyboardState, + showLoadingOverlay = uiState.isConnectingCouple, + navigateToBack = navigateToBack, + onChangeInviteCode = { viewModel.dispatch(OnBoardingIntent.WriteInviteCode(it)) }, + onComplete = { viewModel.dispatch(OnBoardingIntent.ConnectCouple) }, + onCopyInviteCode = { viewModel.dispatch(OnBoardingIntent.CopyInviteCode) }, + ) + } } @Composable private fun InviteCodeScreen( uiModel: InviteCodeUiModel, keyboardState: Keyboard, + showLoadingOverlay: Boolean, navigateToBack: () -> Unit, onChangeInviteCode: (String) -> Unit, onComplete: () -> Unit, @@ -306,6 +328,10 @@ private fun InviteCodeScreen( .padding(horizontal = 20.dp, vertical = 8.dp) .imePadding(), ) + + if (showLoadingOverlay) { + TwixLoadingOverlay() + } } } @@ -353,6 +379,7 @@ private fun InviteCodeScreenPreview() { myInviteCode = "ABCDEFG", isValid = textState.length == 6, ), + showLoadingOverlay = false, onChangeInviteCode = { textState = it }, onComplete = {}, navigateToBack = {}, diff --git a/feature/onboarding/src/main/java/com/twix/onboarding/profile/ProfileScreen.kt b/feature/onboarding/src/main/java/com/twix/onboarding/profile/ProfileScreen.kt index 1231acdf5..0947d89af 100644 --- a/feature/onboarding/src/main/java/com/twix/onboarding/profile/ProfileScreen.kt +++ b/feature/onboarding/src/main/java/com/twix/onboarding/profile/ProfileScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R import com.twix.designsystem.components.button.AppButton +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.text_field.UnderlineTextField import com.twix.designsystem.components.toast.ToastManager @@ -79,6 +80,7 @@ fun ProfileRoute( ProfileScreen( uiModel = uiState.profile, + showLoadingOverlay = uiState.isSubmittingProfile, onCompleted = { viewModel.dispatch(OnBoardingIntent.SubmitNickName) }, @@ -89,6 +91,7 @@ fun ProfileRoute( @Composable private fun ProfileScreen( uiModel: ProfileUiModel, + showLoadingOverlay: Boolean, onCompleted: () -> Unit, onChangeNickName: (String) -> Unit, ) { @@ -98,7 +101,7 @@ private fun ProfileScreen( focusRequester.requestFocus() } - Column( + androidx.compose.foundation.layout.Box( modifier = Modifier .fillMaxSize() @@ -106,69 +109,75 @@ private fun ProfileScreen( .statusBarsPadding() .imePadding(), ) { - Spacer(modifier = Modifier.height(80.dp)) + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.height(80.dp)) - AppText( - text = stringResource(R.string.onboarding_profile_title), - style = AppTextStyle.H3, - color = GrayColor.C500, - modifier = Modifier.padding(start = 24.dp), - ) + AppText( + text = stringResource(R.string.onboarding_profile_title), + style = AppTextStyle.H3, + color = GrayColor.C500, + modifier = Modifier.padding(start = 24.dp), + ) - Spacer(modifier = Modifier.height(32.dp)) - - UnderlineTextField( - value = uiModel.nickname, - placeHolder = stringResource(R.string.onboarding_name_placeholder), - showTrailing = true, - onValueChange = onChangeNickName, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.None), - trailing = { - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_clear_text), + Spacer(modifier = Modifier.height(32.dp)) + + UnderlineTextField( + value = uiModel.nickname, + placeHolder = stringResource(R.string.onboarding_name_placeholder), + showTrailing = true, + onValueChange = onChangeNickName, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.None), + trailing = { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_clear_text), + contentDescription = null, + modifier = Modifier.noRippleClickable { onChangeNickName("") }, + ) + }, + modifier = + Modifier + .focusRequester(focusRequester) + .padding(horizontal = 20.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_check_success), contentDescription = null, - modifier = Modifier.noRippleClickable { onChangeNickName("") }, + tint = if (uiModel.isValid) SystemColor.Success else GrayColor.C300, ) - }, - modifier = - Modifier - .focusRequester(focusRequester) - .padding(horizontal = 20.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.width(4.dp)) - Row( - modifier = Modifier.padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_check_success), - contentDescription = null, - tint = if (uiModel.isValid) SystemColor.Success else GrayColor.C300, - ) - - Spacer(modifier = Modifier.width(4.dp)) + AppText( + style = AppTextStyle.C2, + color = if (uiModel.isValid) SystemColor.Success else GrayColor.C300, + text = stringResource(id = R.string.onboarding_name_helper), + ) + } - AppText( - style = AppTextStyle.C2, - color = if (uiModel.isValid) SystemColor.Success else GrayColor.C300, - text = stringResource(id = R.string.onboarding_name_helper), + Spacer(modifier = Modifier.weight(1f)) + + AppButton( + text = stringResource(R.string.onboarding_profile_button_title), + onClick = { onCompleted() }, + backgroundColor = if (uiModel.isValid) GrayColor.C500 else GrayColor.C100, + textColor = if (uiModel.isValid) CommonColor.White else GrayColor.C300, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), ) } - Spacer(modifier = Modifier.weight(1f)) - - AppButton( - text = stringResource(R.string.onboarding_profile_button_title), - onClick = { onCompleted() }, - backgroundColor = if (uiModel.isValid) GrayColor.C500 else GrayColor.C100, - textColor = if (uiModel.isValid) CommonColor.White else GrayColor.C300, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), - ) + if (showLoadingOverlay) { + TwixLoadingOverlay() + } } } @@ -180,6 +189,7 @@ private fun UnValidProfileScreenPreview() { ProfileScreen( onCompleted = {}, onChangeNickName = {}, + showLoadingOverlay = false, uiModel = ProfileUiModel( nickname = "", @@ -197,6 +207,7 @@ private fun ValidProfileScreenPreview() { ProfileScreen( onCompleted = {}, onChangeNickName = {}, + showLoadingOverlay = false, uiModel = ProfileUiModel( nickname = "", diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt index 60bb1156f..bd5b0c9db 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -26,6 +27,8 @@ import androidx.compose.ui.unit.dp import androidx.core.app.ActivityCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData import com.twix.designsystem.components.toast.model.ToastType @@ -134,6 +137,7 @@ fun PhotologDetailRoute( uiState = uiState, screenHeightPx = screenHeightPx, onBack = navigateToBack, + onRetry = { viewModel.dispatch(PhotologDetailIntent.Retry) }, onClickModify = { navigateToEditor( uiState.goalId, @@ -176,41 +180,65 @@ fun PhotologDetailScreen( uiState: PhotologDetailUiState, screenHeightPx: Float, onBack: () -> Unit, + onRetry: () -> Unit, onClickModify: () -> Unit, onClickReaction: (GoalReactionType) -> Unit, onClickUpload: () -> Unit, onPoke: () -> Unit, onSwipe: () -> Unit, ) { - Column( + Box( Modifier .fillMaxSize() .background(color = CommonColor.White), ) { - PhotologDetailTopBar( - title = uiState.goalName, - canModify = uiState.canModify, - onBack = onBack, - onClickModify = onClickModify, - ) - Spacer(Modifier.height(103.dp)) - - if (uiState.isLoading) { - PhotologCardContent( - uiState = uiState, - isPokeDisabled = uiState.isPokeDisabled, - onSwipe = onSwipe, - onClickUpload = onClickUpload, - onPoke = onPoke, - ) + when { + uiState.showLoading -> { + TwixLoadingOverlay() + } - if (uiState.canReaction) { - ReactionContent( - screenHeightPx = screenHeightPx, - reaction = uiState.partnerPhotolog?.reaction, - onClickReaction = onClickReaction, + uiState.showError -> { + ErrorScreen( + onClickRetry = onRetry, + onClickBack = onBack, ) } + + else -> { + Column( + Modifier + .fillMaxSize() + .background(color = CommonColor.White), + ) { + PhotologDetailTopBar( + title = uiState.goalName, + canModify = uiState.canModify, + onBack = onBack, + onClickModify = onClickModify, + ) + Spacer(Modifier.height(103.dp)) + + PhotologCardContent( + uiState = uiState, + isPokeDisabled = uiState.isPokeDisabled, + onSwipe = onSwipe, + onClickUpload = onClickUpload, + onPoke = onPoke, + ) + + if (uiState.canReaction) { + ReactionContent( + screenHeightPx = screenHeightPx, + reaction = uiState.partnerPhotolog?.reaction, + onClickReaction = onClickReaction, + ) + } + } + + if (uiState.showOverlayLoading || uiState.isPoking) { + TwixLoadingOverlay() + } + } } } } @@ -252,6 +280,7 @@ private fun PhotologDetailScreenPreview( uiState = previewState, screenHeightPx = 0f, onBack = {}, + onRetry = {}, onClickModify = {}, onClickReaction = {}, onClickUpload = {}, diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt index 2f0dbc9c4..4df175816 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt @@ -6,6 +6,7 @@ import com.twix.designsystem.R import com.twix.designsystem.components.toast.model.ToastType import com.twix.domain.model.enums.BetweenUs import com.twix.domain.model.enums.GoalReactionType +import com.twix.domain.model.photolog.PhotoLogs import com.twix.domain.model.poke.PokeGoalResult import com.twix.domain.repository.PhotoLogRepository import com.twix.domain.usecase.PokeGoalUseCase @@ -14,6 +15,7 @@ import com.twix.photolog.detail.contract.PhotologDetailIntent import com.twix.photolog.detail.contract.PhotologDetailSideEffect import com.twix.photolog.detail.contract.PhotologDetailUiState import com.twix.photolog.detail.contract.toUiState +import com.twix.result.AppResult import com.twix.ui.base.BaseViewModel import com.twix.util.bus.GoalRefreshBus import com.twix.util.bus.PhotologRefreshBus @@ -69,24 +71,34 @@ class PhotologDetailViewModel( private fun fetchPhotolog() { launchResult( - block = { photologRepository.fetchPhotologs(argTargetDate, argGoalId) }, - onSuccess = { - reduce { - it.toUiState( - argGoalId, - argBetweenUs, - argTargetDate, - argIsCompleted, - ) - } - }, - onError = { - showToast(R.string.toast_photolog_detail_fetch_fail, ToastType.ERROR) - }, - onFinally = { reduce { copy(isLoading = true) } }, + block = ::fetchPhotologs, + onSuccess = ::handleFetchPhotologSuccess, + onError = { handleFetchPhotologError() }, ) } + private suspend fun fetchPhotologs(): AppResult = photologRepository.fetchPhotologs(argTargetDate, argGoalId) + + private fun handleFetchPhotologSuccess(photoLogs: PhotoLogs) { + reduce { + photoLogs + .toUiState( + argGoalId, + argBetweenUs, + argTargetDate, + argIsCompleted, + ).copy( + hasShownMyReaction = currentState.hasShownMyReaction, + pokeCooldownRemaining = currentState.pokeCooldownRemaining, + ) + } + } + + private suspend fun handleFetchPhotologError() { + if (!currentState.hasLoadedContent) return + showToast(R.string.toast_photolog_detail_fetch_fail) + } + @OptIn(FlowPreview::class) private fun collectReactionFlow() { viewModelScope.launch { @@ -108,7 +120,7 @@ class PhotologDetailViewModel( onSuccess = {}, onError = { rollbackReaction() - showToast(R.string.toast_reaction_fail, ToastType.ERROR) + showToast(R.string.toast_reaction_fail) }, ) } @@ -136,6 +148,7 @@ class PhotologDetailViewModel( override suspend fun handleIntent(intent: PhotologDetailIntent) { when (intent) { + PhotologDetailIntent.Retry -> fetchPhotolog() is PhotologDetailIntent.Reaction -> reduceReaction(intent.type) PhotologDetailIntent.Poke -> pokeToPartner() PhotologDetailIntent.SwipeCard -> reduceShownCard() @@ -158,24 +171,43 @@ class PhotologDetailViewModel( private fun pokeToPartner() { viewModelScope.launch { - reduce { copy(isPoking = true) } - when (val result = pokeGoalUseCase.invoke(argGoalId)) { - is PokeGoalResult.Success -> { - reduce { copy(isPoking = false, pokeCooldownRemaining = PokeGoalUseCase.COOLDOWN_MS) } - tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeToast) - } - is PokeGoalResult.OnCooldown -> { - reduce { copy(isPoking = false) } - tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeCooldownToast(result.remainingMs)) - } - PokeGoalResult.Error -> { - reduce { copy(isPoking = false) } - showToast(R.string.toast_poke_goal_failed, ToastType.ERROR) - } - } + startPokeLoading() + handlePokeResult(pokeGoalUseCase.invoke(argGoalId)) } } + private fun startPokeLoading() { + reduce { copy(isPoking = true) } + } + + private suspend fun handlePokeResult(result: PokeGoalResult) { + when (result) { + is PokeGoalResult.Success -> handlePokeSuccess() + is PokeGoalResult.OnCooldown -> handlePokeCooldown(result.remainingMs) + PokeGoalResult.Error -> handlePokeError() + } + } + + private fun handlePokeSuccess() { + reduce { + copy( + isPoking = false, + pokeCooldownRemaining = PokeGoalUseCase.COOLDOWN_MS, + ) + } + tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeToast) + } + + private fun handlePokeCooldown(remainingMs: Long) { + reduce { copy(isPoking = false) } + tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeCooldownToast(remainingMs)) + } + + private suspend fun handlePokeError() { + reduce { copy(isPoking = false) } + showToast(R.string.toast_poke_goal_failed) + } + private fun reduceShownCard() { reduce { toggleBetweenUs() } } @@ -193,11 +225,13 @@ class PhotologDetailViewModel( reduce { copy(hasShownMyReaction = true) } } - private suspend fun showToast( - message: Int, - type: ToastType, - ) { - emitSideEffect(PhotologDetailSideEffect.ShowToast(message, type)) + private suspend fun showToast(message: Int) { + emitSideEffect( + PhotologDetailSideEffect.ShowToast( + message, + ToastType.ERROR, + ), + ) } companion object { diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt index 67ef0f5d4..13f882126 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailIntent.kt @@ -4,6 +4,8 @@ import com.twix.domain.model.enums.GoalReactionType import com.twix.ui.base.Intent sealed interface PhotologDetailIntent : Intent { + data object Retry : PhotologDetailIntent + data class Reaction( val type: GoalReactionType, ) : PhotologDetailIntent diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt index df278140f..5ac3ff551 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/contract/PhotologDetailUiState.kt @@ -6,7 +6,8 @@ import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.photolog.PhotoLogs import com.twix.domain.model.photolog.PhotologDetail import com.twix.photolog.detail.component.reaction.ReactionUiModel -import com.twix.ui.base.State +import com.twix.result.AppError +import com.twix.ui.base.ContentLoadableState import java.time.LocalDate @Immutable @@ -21,14 +22,13 @@ data class PhotologDetailUiState( val myPhotolog: PhotologDetail? = null, val partnerPhotolog: PhotologDetail? = null, val isCompletedGoal: Boolean = false, + override val hasLoadedContent: Boolean = false, /** * 내 인증샷에 상대방이 리액션을 남겼을 경우 최초 1회 인터렉션 렌더링을 위한 변수 */ val hasShownMyReaction: Boolean = false, - /** - * 초기값으로 인해 찌르기/업로드 버튼이 렌더링 되는 것을 막기 위한 변수 - */ - val isLoading: Boolean = false, + override val isLoading: Boolean = true, + override val error: AppError? = null, /** * 찌르기 API 호출 중 여부 - 낙관적 UI를 위해 버튼 중복 클릭 방지에 사용 */ @@ -37,7 +37,7 @@ data class PhotologDetailUiState( * 찌르기 쿨타임 잔여 시간(ms). 0보다 크면 쿨타임 중 */ val pokeCooldownRemaining: Long = 0L, -) : State { +) : ContentLoadableState { val isPokeDisabled: Boolean get() = isPoking || pokeCooldownRemaining > 0 @@ -159,6 +159,15 @@ data class PhotologDetailUiState( */ val myReaction: ReactionUiModel? get() = myPhotolog?.reaction?.let { ReactionUiModel.find(it) } + + override fun copyState( + isLoading: Boolean, + error: AppError?, + ): ContentLoadableState = + copy( + isLoading = isLoading, + error = error, + ) } fun PhotoLogs.toUiState( @@ -170,7 +179,7 @@ fun PhotoLogs.toUiState( val currentGoalPhotolog = goals.firstOrNull { it.goalId == goalId - } ?: return PhotologDetailUiState() + } ?: return PhotologDetailUiState(isLoading = false) return PhotologDetailUiState( goalId = goalId, @@ -183,5 +192,7 @@ fun PhotoLogs.toUiState( myPhotolog = currentGoalPhotolog.myPhotolog, partnerPhotolog = currentGoalPhotolog.partnerPhotolog, isCompletedGoal = isCompletedGoal, + hasLoadedContent = true, + isLoading = false, ) } diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/preview/PhotologDetailPreviewProvider.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/preview/PhotologDetailPreviewProvider.kt index 8b8f23890..ddec17f2b 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/preview/PhotologDetailPreviewProvider.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/preview/PhotologDetailPreviewProvider.kt @@ -6,6 +6,7 @@ import com.twix.domain.model.enums.GoalIconType import com.twix.domain.model.enums.GoalReactionType import com.twix.domain.model.photolog.PhotologDetail import com.twix.photolog.detail.contract.PhotologDetailUiState +import com.twix.result.AppError class PhotologDetailPreviewProvider : PreviewParameterProvider { override val values = @@ -29,7 +30,7 @@ class PhotologDetailPreviewProvider : PreviewParameterProvider Unit, + onRetry: () -> Unit, onClickSave: () -> Unit, onCommentChanged: (String) -> Unit, onFocusChanged: (Boolean) -> Unit, @@ -166,51 +170,70 @@ fun PhotologEditorScreen( .background(color = CommonColor.White) .noRippleClickable { focusManager.clearFocus() }, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - PhotologEditorTopBar( - title = uiState.goalName, - onBack = onBack, - onClickSave = onClickSave, - ) - - Spacer(Modifier.height(103.dp)) + when { + uiState.showLoading -> { + TwixLoadingOverlay() + } - PhotologCard( - modifier = - Modifier - .onGloballyPositioned { coordinates -> - val bottom = coordinates.boundsInParent().bottom - if (photologBottom != bottom) { - photologBottom = bottom - } - }, - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(uiState.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, + uiState.showError -> { + ErrorScreen( + onClickRetry = onRetry, + onClickBack = onBack, ) } - Spacer(Modifier.height(101.dp)) + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PhotologEditorTopBar( + title = uiState.goalName, + onBack = onBack, + onClickSave = onClickSave, + ) - RetakeButton(onClickRetake = onClickRetake) - } + Spacer(Modifier.height(103.dp)) - CommentAnchorFrame( - uiModel = uiState.comment, - anchorBottom = photologBottom, - paddingBottom = 24.dp, - onCommentChanged = onCommentChanged, - onFocusChanged = onFocusChanged, - ) + PhotologCard( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + val bottom = coordinates.boundsInParent().bottom + if (photologBottom != bottom) { + photologBottom = bottom + } + }, + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(uiState.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + + Spacer(Modifier.height(101.dp)) + + RetakeButton(onClickRetake = onClickRetake) + } + + CommentAnchorFrame( + uiModel = uiState.comment, + anchorBottom = photologBottom, + paddingBottom = 24.dp, + onCommentChanged = onCommentChanged, + onFocusChanged = onFocusChanged, + ) + + if (uiState.isSaving) { + TwixLoadingOverlay() + } + } + } } } @@ -247,8 +270,10 @@ private fun PhotologEditorScreenPreview() { PhotologEditorUiState( nickname = "페토", goalName = "아이스크림 먹기", + isLoading = false, ), onBack = {}, + onRetry = {}, onClickSave = {}, onFocusChanged = {}, onClickRetake = {}, diff --git a/feature/photolog/editor/src/main/java/com/twix/photolog/editor/PhotologEditorViewModel.kt b/feature/photolog/editor/src/main/java/com/twix/photolog/editor/PhotologEditorViewModel.kt index 98dc2bc9d..45dac27dd 100644 --- a/feature/photolog/editor/src/main/java/com/twix/photolog/editor/PhotologEditorViewModel.kt +++ b/feature/photolog/editor/src/main/java/com/twix/photolog/editor/PhotologEditorViewModel.kt @@ -39,6 +39,7 @@ class PhotologEditorViewModel( override suspend fun handleIntent(intent: PhotologEditorIntent) { when (intent) { + PhotologEditorIntent.Retry -> fetchPhotolog() is PhotologEditorIntent.CommentFocusChanged -> reduceCommentFocus(intent.isFocused) is PhotologEditorIntent.ModifyComment -> reduceComment(intent.value) PhotologEditorIntent.Save -> modifyComment() @@ -60,6 +61,7 @@ class PhotologEditorViewModel( showToast(R.string.toast_comment_not_modified, ToastType.DEFAULT) } else { launchResult( + onStart = { reduce { copy(isSaving = true) } }, block = { launchModifyComment() }, onSuccess = { detailRefreshBus.notifyChanged(PhotologRefreshBus.Publisher.EDITOR) @@ -69,6 +71,7 @@ class PhotologEditorViewModel( onError = { showToast(R.string.toast_comment_modify_fail, ToastType.ERROR) }, + onFinally = { reduce { copy(isSaving = false) } }, ) } } @@ -87,9 +90,10 @@ class PhotologEditorViewModel( private fun fetchPhotolog() { launchResult( block = { photologRepository.fetchPhotologs(argTargetDate, argGoalId) }, - onSuccess = { reduce { it.toEditorUiState(argGoalId, argTargetDate) } }, - onError = { - showToast(R.string.toast_photolog_detail_fetch_fail, ToastType.ERROR) + onSuccess = { + reduce { + it.toEditorUiState(argGoalId, argTargetDate) + } }, ) } diff --git a/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorIntent.kt b/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorIntent.kt index b748f8ed5..504e0e2b2 100644 --- a/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorIntent.kt +++ b/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorIntent.kt @@ -3,6 +3,8 @@ package com.twix.photolog.editor.contract import com.twix.ui.base.Intent sealed interface PhotologEditorIntent : Intent { + data object Retry : PhotologEditorIntent + data object Save : PhotologEditorIntent data class CommentFocusChanged( diff --git a/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorUiState.kt b/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorUiState.kt index 22099d685..1a31ec1d5 100644 --- a/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorUiState.kt +++ b/feature/photolog/editor/src/main/java/com/twix/photolog/editor/contract/PhotologEditorUiState.kt @@ -3,7 +3,8 @@ package com.twix.photolog.editor.contract import androidx.compose.runtime.Immutable import com.twix.designsystem.components.comment.model.CommentUiModel import com.twix.domain.model.photolog.PhotoLogs -import com.twix.ui.base.State +import com.twix.result.AppError +import com.twix.ui.base.ContentLoadableState import java.time.LocalDate @Immutable @@ -16,13 +17,26 @@ data class PhotologEditorUiState( val imageUrl: String = "", val comment: CommentUiModel = CommentUiModel(), val originComment: String = "", -) : State { + override val hasLoadedContent: Boolean = false, + val isSaving: Boolean = false, + override val isLoading: Boolean = true, + override val error: AppError? = null, +) : ContentLoadableState { val isCommentNotChanged: Boolean get() = comment.value == originComment val imageName: String get() = imageUrl.split(IMAGE_NAME_SEPARATOR).last() + override fun copyState( + isLoading: Boolean, + error: AppError?, + ): ContentLoadableState = + copy( + isLoading = isLoading, + error = error, + ) + companion object { private const val IMAGE_NAME_SEPARATOR = "/" } @@ -44,5 +58,7 @@ internal fun PhotoLogs.toEditorUiState( imageUrl = myPhotolog?.imageUrl.orEmpty(), comment = CommentUiModel(myPhotolog?.comment.orEmpty()), originComment = myPhotolog?.comment.orEmpty(), + hasLoadedContent = true, + isLoading = false, ) } diff --git a/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt b/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt index ba5cc95d5..83dfc7190 100644 --- a/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt +++ b/feature/settings/src/main/java/com/twix/settings/SettingsIntent.kt @@ -3,6 +3,8 @@ package com.twix.settings import com.twix.ui.base.Intent sealed interface SettingsIntent : Intent { + data object Retry : SettingsIntent + data class SetNickName( val nickName: String, ) : SettingsIntent diff --git a/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt index b60511cf1..8dead48a9 100644 --- a/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/twix/settings/SettingsScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.topbar.CommonTopBar import com.twix.designsystem.theme.CommonColor @@ -44,6 +46,7 @@ fun SettingsRoute( SettingsScreen( uiState = uiState, onBack = popBackStack, + onRetry = { viewModel.dispatch(SettingsIntent.Retry) }, onAccountClick = navigateToSettingsAccount, onAboutClick = navigateToSettingsAbout, ) @@ -53,56 +56,63 @@ fun SettingsRoute( private fun SettingsScreen( uiState: SettingsUiState, onBack: () -> Unit, + onRetry: () -> Unit, onAccountClick: () -> Unit, onAboutClick: () -> Unit, ) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(CommonColor.White), - ) { - CommonTopBar( - title = stringResource(R.string.word_setting), - left = { - Image( - painter = painterResource(R.drawable.ic_arrow3_left), - contentDescription = "back", - modifier = - Modifier - .padding(18.dp) - .size(24.dp) - .noRippleClickable(onClick = onBack), + when { + uiState.showLoading -> TwixLoadingOverlay() + uiState.showError -> ErrorScreen(onClickRetry = onRetry, onClickBack = onBack) + else -> { + Column( + modifier = + Modifier + .fillMaxSize() + .background(CommonColor.White), + ) { + CommonTopBar( + title = stringResource(R.string.word_setting), + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + }, ) - }, - ) - Spacer(Modifier.height(20.dp)) + Spacer(Modifier.height(20.dp)) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - ) { - ProfileInfo(nickname = uiState.nickName) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + ProfileInfo(nickname = uiState.nickName) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(24.dp)) - SettingsMenuFrame { - SettingsMenuItem( - resId = R.drawable.ic_profile_small, - title = stringResource(R.string.word_account), - onClick = onAccountClick, - ) + SettingsMenuFrame { + SettingsMenuItem( + resId = R.drawable.ic_profile_small, + title = stringResource(R.string.word_account), + onClick = onAccountClick, + ) - HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) - SettingsMenuItem( - resId = R.drawable.ic_info, - title = stringResource(R.string.word_information), - onClick = onAboutClick, - ) + SettingsMenuItem( + resId = R.drawable.ic_info, + title = stringResource(R.string.word_information), + onClick = onAboutClick, + ) + } + } } } } diff --git a/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt index 724377e5b..303a5c4e2 100644 --- a/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/twix/settings/SettingsViewModel.kt @@ -19,6 +19,7 @@ class SettingsViewModel( override suspend fun handleIntent(intent: SettingsIntent) { when (intent) { + SettingsIntent.Retry -> fetchUserInfo() is SettingsIntent.SetNickName -> setNickName(intent.nickName) SettingsIntent.Logout -> logout() SettingsIntent.WithdrawAccount -> withdrawAccount() @@ -28,7 +29,15 @@ class SettingsViewModel( private fun fetchUserInfo() { launchResult( block = { userRepository.fetchUserInfo() }, - onSuccess = { reduce { copy(nickName = it.name, email = it.email) } }, + onSuccess = { + reduce { + copy( + nickName = it.name, + email = it.email, + hasLoadedContent = true, + ) + } + }, ) } @@ -37,8 +46,13 @@ class SettingsViewModel( } private fun logout() { + if (currentState.isAccountActionInFlight) return + + reduce { copy(isAccountActionInFlight = true) } + launchResult( block = { authRepository.logout() }, + onFinally = { reduce { copy(isAccountActionInFlight = false) } }, onSuccess = { tokenRegistrar.unregisterCurrentToken() tryEmitSideEffect(SettingsSideEffect.ShowToast(R.string.toast_logout_completed, ToastType.SUCCESS)) @@ -49,8 +63,13 @@ class SettingsViewModel( } private fun withdrawAccount() { + if (currentState.isAccountActionInFlight) return + + reduce { copy(isAccountActionInFlight = true) } + launchResult( block = { authRepository.withdrawAccount() }, + onFinally = { reduce { copy(isAccountActionInFlight = false) } }, onSuccess = { tryEmitSideEffect(SettingsSideEffect.ShowToast(R.string.toast_account_deleted, ToastType.SUCCESS)) tryEmitSideEffect(SettingsSideEffect.NavigateToLogin) diff --git a/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt b/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt index e867c4c69..3dafcfbab 100644 --- a/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt +++ b/feature/settings/src/main/java/com/twix/settings/account/SettingsAccountScreen.kt @@ -21,8 +21,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.twix.designsystem.R import com.twix.designsystem.components.dialog.CommonDialog +import com.twix.designsystem.components.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager import com.twix.designsystem.components.toast.model.ToastData @@ -35,6 +38,7 @@ import com.twix.settings.SettingsSideEffect import com.twix.settings.SettingsViewModel import com.twix.settings.component.SettingsMenuFrame import com.twix.settings.component.SettingsMenuItem +import com.twix.settings.model.SettingsUiState import com.twix.ui.base.ObserveAsEvents import com.twix.ui.extension.noRippleClickable import org.koin.compose.koinInject @@ -46,6 +50,7 @@ fun SettingsAccountRoute( popBackStack: () -> Unit, navigateToLogin: () -> Unit, ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val currentContext by rememberUpdatedState(context) @@ -57,7 +62,9 @@ fun SettingsAccountRoute( } SettingsAccountScreen( + uiState = uiState, onBack = popBackStack, + onRetry = { viewModel.dispatch(SettingsIntent.Retry) }, onLogout = { viewModel.dispatch(SettingsIntent.Logout) }, onWithdrawAccount = { viewModel.dispatch(SettingsIntent.WithdrawAccount) }, ) @@ -65,89 +72,109 @@ fun SettingsAccountRoute( @Composable private fun SettingsAccountScreen( + uiState: SettingsUiState, onBack: () -> Unit, + onRetry: () -> Unit, onLogout: () -> Unit, onWithdrawAccount: () -> Unit, ) { var showWithdrawDialog by remember { mutableStateOf(false) } - Column( - modifier = - Modifier - .fillMaxSize() - .background(CommonColor.White), - ) { - CommonTopBar( - title = stringResource(R.string.word_account), - left = { - Image( - painter = painterResource(R.drawable.ic_arrow3_left), - contentDescription = "back", + when { + uiState.showLoading -> TwixLoadingOverlay() + uiState.showError -> ErrorScreen(onClickRetry = onRetry, onClickBack = onBack) + else -> { + Column( + modifier = + Modifier + .fillMaxSize() + .background(CommonColor.White), + ) { + CommonTopBar( + title = stringResource(R.string.word_account), + left = { + Image( + painter = painterResource(R.drawable.ic_arrow3_left), + contentDescription = "back", + modifier = + Modifier + .padding(18.dp) + .size(24.dp) + .noRippleClickable(onClick = onBack), + ) + }, + ) + + Spacer(Modifier.height(20.dp)) + + SettingsMenuFrame( modifier = Modifier - .padding(18.dp) - .size(24.dp) - .noRippleClickable(onClick = onBack), - ) - }, - ) - - Spacer(Modifier.height(20.dp)) - - SettingsMenuFrame( - modifier = - Modifier - .padding(horizontal = 20.dp), - ) { - SettingsMenuItem( - title = stringResource(R.string.word_logout), - onClick = onLogout, - ) + .padding(horizontal = 20.dp), + ) { + SettingsMenuItem( + title = stringResource(R.string.word_logout), + onClick = { + if (!uiState.isAccountActionInFlight) { + onLogout() + } + }, + ) - HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) + HorizontalDivider(thickness = 1.dp, color = GrayColor.C500) - SettingsMenuItem( - title = stringResource(R.string.action_withdraw_account), - onClick = { showWithdrawDialog = true }, - ) - } - } + SettingsMenuItem( + title = stringResource(R.string.action_withdraw_account), + onClick = { + if (!uiState.isAccountActionInFlight) { + showWithdrawDialog = true + } + }, + ) + } + } - CommonDialog( - visible = showWithdrawDialog, - confirmText = stringResource(R.string.word_cancel), - dismissText = stringResource(R.string.action_withdraw_account), - onDismiss = { - showWithdrawDialog = false - onWithdrawAccount() - }, - onConfirm = { showWithdrawDialog = false }, - onDismissRequest = { showWithdrawDialog = false }, - content = { - Image( - painter = painterResource(R.drawable.ic_warning), - contentDescription = "warning", - modifier = - Modifier - .size(60.dp), - ) + if (uiState.showOverlayLoading) { + TwixLoadingOverlay() + } - Spacer(Modifier.height(12.dp)) + CommonDialog( + visible = showWithdrawDialog, + confirmText = stringResource(R.string.word_cancel), + dismissText = stringResource(R.string.action_withdraw_account), + onDismiss = { + showWithdrawDialog = false + onWithdrawAccount() + }, + onConfirm = { showWithdrawDialog = false }, + onDismissRequest = { showWithdrawDialog = false }, + content = { + Image( + painter = painterResource(R.drawable.ic_warning), + contentDescription = "warning", + modifier = + Modifier + .size(60.dp), + ) - AppText( - text = stringResource(R.string.dialog_withdraw_account_title), - color = GrayColor.C500, - style = AppTextStyle.T1, - ) + Spacer(Modifier.height(12.dp)) - Spacer(Modifier.height(8.dp)) + AppText( + text = stringResource(R.string.dialog_withdraw_account_title), + color = GrayColor.C500, + style = AppTextStyle.T1, + ) - AppText( - text = stringResource(R.string.dialog_withdraw_account_content), - color = GrayColor.C400, - style = AppTextStyle.B2, - textAlign = TextAlign.Center, + Spacer(Modifier.height(8.dp)) + + AppText( + text = stringResource(R.string.dialog_withdraw_account_content), + color = GrayColor.C400, + style = AppTextStyle.B2, + textAlign = TextAlign.Center, + ) + }, ) - }, - ) + } + } } diff --git a/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt b/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt index fa50ef184..7088680d0 100644 --- a/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt +++ b/feature/settings/src/main/java/com/twix/settings/model/SettingsUiState.kt @@ -1,16 +1,18 @@ package com.twix.settings.model import com.twix.result.AppError -import com.twix.ui.base.LoadableState +import com.twix.ui.base.ContentLoadableState data class SettingsUiState( val nickName: String = "", val email: String = "", - override val isLoading: Boolean = false, + override val hasLoadedContent: Boolean = false, + val isAccountActionInFlight: Boolean = false, + override val isLoading: Boolean = true, override val error: AppError? = null, -) : LoadableState { - override fun copyLoadableState( +) : ContentLoadableState { + override fun copyState( isLoading: Boolean, error: AppError?, - ): LoadableState = copy(isLoading = isLoading, error = error) + ): ContentLoadableState = copy(isLoading = isLoading, error = error) } 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 index 18f9d2b55..8df4be08c 100644 --- 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 @@ -35,6 +35,8 @@ 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.error.ErrorScreen +import com.twix.designsystem.components.loading.TwixLoadingOverlay import com.twix.designsystem.components.stats.StatsCalendar import com.twix.designsystem.components.text.AppText import com.twix.designsystem.components.toast.ToastManager @@ -95,6 +97,7 @@ fun StatsDetailRoute( StatsDetailScreen( uiState = uiState, onBack = onBack, + onRetry = { viewModel.dispatch(StatsDetailIntent.Retry) }, onSelectDate = { selectedDate -> viewModel.dispatch(StatsDetailIntent.SelectDate(selectedDate)) }, onPreviousMonth = { viewModel.dispatch(StatsDetailIntent.PreviousMonth) }, onNextMonth = { viewModel.dispatch(StatsDetailIntent.NextMonth) }, @@ -108,6 +111,7 @@ fun StatsDetailRoute( fun StatsDetailScreen( uiState: StatsDetailUiState, onBack: () -> Unit, + onRetry: () -> Unit, onSelectDate: (LocalDate) -> Unit, onPreviousMonth: () -> Unit, onNextMonth: () -> Unit, @@ -121,126 +125,156 @@ fun StatsDetailScreen( val isInProgressStatsDetail = !uiState.detail.isCompleted Box { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .background(GrayColor.C050), - ) { - StatsDetailTopbar( - goalName = uiState.detail.goalName, - isInProgressStatsDetail = isInProgressStatsDetail, - popupMenuVisibility = popupMenuVisibility, - onBack = onBack, - onClickAction = { - if (isInProgressStatsDetail) { - popupMenuVisibility = true - } else { - statsDeleteDialogVisibility = true - } - }, - onDismiss = { popupMenuVisibility = false }, - onClickPopupEdit = { - popupMenuVisibility = false - onClickPopupEdit() - }, - onClickPopupEnd = { - popupMenuVisibility = false - onClickPopupEnd() - }, - onClickPopupDelete = { - popupMenuVisibility = false - statsDeleteDialogVisibility = true - }, - ) - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 32.dp), - ) { - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_hug), - contentDescription = null, + when { + uiState.showLoading -> { + TwixLoadingOverlay( modifier = Modifier - .align(Alignment.TopStart) - .padding(start = 20.dp), + .fillMaxSize() + .background(GrayColor.C050), ) + } + uiState.showError -> { + ErrorScreen( + onClickRetry = onRetry, + onClickBack = onBack, + modifier = Modifier.background(GrayColor.C050), + ) + } + + else -> { Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(top = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .fillMaxSize() + .verticalScroll(scrollState) + .background(GrayColor.C050), ) { - CalendarNavigator( - currentDate = uiState.detail.currentDate, - onPreviousMonth = onPreviousMonth, - onNextMonth = onNextMonth, - hasPrevious = uiState.hasPrevious, - hasNext = uiState.hasNext, + StatsDetailTopbar( + goalName = uiState.detail.goalName, + isInProgressStatsDetail = isInProgressStatsDetail, + popupMenuVisibility = popupMenuVisibility, + onBack = onBack, + onClickAction = { + if (isInProgressStatsDetail) { + popupMenuVisibility = true + } else { + statsDeleteDialogVisibility = true + } + }, + onDismiss = { popupMenuVisibility = false }, + onClickPopupEdit = { + popupMenuVisibility = false + onClickPopupEdit() + }, + onClickPopupEnd = { + popupMenuVisibility = false + onClickPopupEnd() + }, + onClickPopupDelete = { + popupMenuVisibility = false + statsDeleteDialogVisibility = true + }, ) 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), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp), ) { - StatsCalendar( - uiModel = uiState.calendarUiModel, - onSelectedDate = onSelectDate, + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_hug), + contentDescription = null, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(start = 20.dp), + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(top = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CalendarNavigator( + currentDate = uiState.detail.currentDate, + 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), ) } + + Spacer(Modifier.height(44.dp)) + + SummaryContent(uiState.summary) } - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_plane), - contentDescription = null, - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(end = 27.dp), + CommonDialog( + visible = statsDeleteDialogVisibility, + confirmText = stringResource(R.string.word_delete), + dismissText = stringResource(R.string.word_cancel), + onDismissRequest = { statsDeleteDialogVisibility = false }, + onConfirm = { + statsDeleteDialogVisibility = false + 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, + ) + }, ) - } - - Spacer(Modifier.height(44.dp)) - SummaryContent(uiState.summary) + if (uiState.showOverlayLoading) { + TwixLoadingOverlay( + modifier = + Modifier + .fillMaxSize() + .background(GrayColor.C050.copy(alpha = 0.7f)), + ) + } + } } - - CommonDialog( - visible = statsDeleteDialogVisibility, - confirmText = stringResource(R.string.word_delete), - dismissText = stringResource(R.string.word_cancel), - onDismissRequest = { statsDeleteDialogVisibility = false }, - onConfirm = { - statsDeleteDialogVisibility = false - 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, - ) - }, - ) } } @@ -294,6 +328,7 @@ private fun StatsDetailScreenPreview( StatsDetailScreen( uiState = uiState, onBack = {}, + onRetry = {}, onSelectDate = {}, onPreviousMonth = {}, onNextMonth = {}, 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 index aedbd4da5..6a55350fa 100644 --- 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 @@ -13,6 +13,7 @@ import com.twix.domain.repository.GoalRepository import com.twix.domain.repository.StatsRepository import com.twix.navigation.NavRoutes import com.twix.result.AppResult +import com.twix.result.errorOrNull import com.twix.stats.detail.contract.StatsDetailSideEffect import com.twix.stats.detail.contract.StatsDetailUiState import com.twix.ui.base.BaseViewModel @@ -67,6 +68,9 @@ class StatsDetailViewModel( val initialDate = LocalDate.parse(argDate).let(YearMonth::from) viewModelScope.launch { + reduce { + copyState(isLoading = true, error = null) as StatsDetailUiState + } val (summary, detail) = fetchStats(initialDate) handleFetchStatsDetailResult(summary, detail, initialDate) } @@ -79,19 +83,28 @@ class StatsDetailViewModel( summaryDeferred.await() to detailDeferred.await() } - private suspend fun handleFetchStatsDetailResult( + private fun handleFetchStatsDetailResult( summary: AppResult, detail: AppResult, initialDate: YearMonth, ) { if (summary is AppResult.Success && detail is AppResult.Success) { - reduce { copy(summary = summary.data) } + reduce { + copy( + summary = summary.data, + ) + } reduceStatsDetail(detail.data) } else { if (summary is AppResult.Error) handleError(summary.error) if (detail is AppResult.Error) handleError(detail.error) clearCalendarOnError(initialDate) - showToast(R.string.toast_fetch_stats_failed, ToastType.ERROR) + reduce { + copyState( + isLoading = false, + error = listOfNotNull(summary.errorOrNull(), detail.errorOrNull()).firstOrNull(), + ) as StatsDetailUiState + } } } @@ -105,6 +118,8 @@ class StatsDetailViewModel( currentDate = result.currentDate, completedDate = result.completedDate, ), + hasLoadedContent = true, + isLoading = false, ) } } @@ -144,7 +159,6 @@ class StatsDetailViewModel( block = { statsRepository.fetchStatsDetail(argGoalId, date) }, onSuccess = { reduceStatsDetail(it) }, onError = { - clearCalendarOnError(date) showToast(R.string.toast_fetch_stats_failed, ToastType.ERROR) }, ) @@ -193,6 +207,7 @@ class StatsDetailViewModel( override suspend fun handleIntent(intent: StatsDetailIntent) { when (intent) { + StatsDetailIntent.Retry -> fetchInitialStats() is StatsDetailIntent.SelectDate -> navigateToPhotologDetail(intent.date) StatsDetailIntent.GoalEdit -> navigateToGoalEditor() StatsDetailIntent.PreviousMonth -> fetchPreviousMonth() @@ -222,13 +237,11 @@ class StatsDetailViewModel( private fun fetchPreviousMonth() { val previousMonth = currentState.detail.currentDate.minusMonths(1) - reduce { copy(detail = detail.copy(currentDate = previousMonth)) } monthChangeFlow.tryEmit(YearMonth.from(previousMonth)) } private fun fetchNextMonth() { val nextMonth = currentState.detail.currentDate.plusMonths(1) - reduce { copy(detail = detail.copy(currentDate = nextMonth)) } monthChangeFlow.tryEmit(YearMonth.from(nextMonth)) } 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 index 63fd000d4..d7abffa19 100644 --- 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 @@ -4,6 +4,8 @@ import com.twix.ui.base.Intent import java.time.LocalDate sealed interface StatsDetailIntent : Intent { + data object Retry : StatsDetailIntent + data class SelectDate( val date: LocalDate, ) : 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 8dfb34dd4..1c30efa83 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 @@ -4,7 +4,8 @@ import androidx.compose.runtime.Immutable import com.twix.designsystem.components.stats.model.StatsCalendarUiModel import com.twix.domain.model.stats.detail.StatsDetail import com.twix.domain.model.stats.detail.StatsSummary -import com.twix.ui.base.State +import com.twix.result.AppError +import com.twix.ui.base.ContentLoadableState import java.time.LocalDate import java.time.YearMonth @@ -13,7 +14,10 @@ data class StatsDetailUiState( val detail: StatsDetail = StatsDetail.EMPTY, val summary: StatsSummary = StatsSummary.EMPTY, val calendarUiModel: StatsCalendarUiModel = StatsCalendarUiModel(), -) : State { + override val hasLoadedContent: Boolean = false, + override val isLoading: Boolean = true, + override val error: AppError? = null, +) : ContentLoadableState { val hasNext: Boolean get() { val limitYm = YearMonth.from(summary.endDate ?: LocalDate.now()) @@ -28,4 +32,13 @@ data class StatsDetailUiState( val previousYm = YearMonth.from(detail.currentDate).minusMonths(1) return previousYm >= limitYm } + + override fun copyState( + isLoading: Boolean, + error: AppError?, + ): ContentLoadableState = + copy( + isLoading = isLoading, + error = error, + ) } 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 066081976..1faa8af32 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 @@ -7,7 +7,9 @@ import com.twix.domain.model.enums.RepeatCycle import com.twix.domain.model.stats.detail.CompletedDate import com.twix.domain.model.stats.detail.StatsDetail import com.twix.domain.model.stats.detail.StatsSummary +import com.twix.result.AppError import com.twix.stats.detail.contract.StatsDetailUiState +import java.io.IOException import java.time.LocalDate class StatsDetailUiStatePreviewProvider : PreviewParameterProvider { @@ -64,11 +66,29 @@ class StatsDetailUiStatePreviewProvider : PreviewParameterProvider