Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4f113a2
✨ Feat: .gitignore에 AGENTS.md 및 .agents 경로 추가
chanho0908 May 13, 2026
9d24e8d
♻️ Refactor: 인증샷 상세 LoadableState 적용
chanho0908 May 13, 2026
60b96a9
♻️ Refactor: PhotologEditor 검수 완료
chanho0908 May 13, 2026
17996e3
✨ Feat: 설정 화면 로딩 상태 적용 완료
chanho0908 May 13, 2026
2b8a92a
✨ Feat: 로그인 화면 로딩 상태 적용 완료
chanho0908 May 13, 2026
d55c288
✨ Feat: 설정 화면 재시도 로직 컨벤션 통일
chanho0908 May 14, 2026
9b832f7
♻️ Refactor: OnBoardingLoadingAction을 별도 파일로 분리
chanho0908 May 14, 2026
b1844b4
✨ Feat: 통계 화면 로딩 에러 UI 적용
chanho0908 May 14, 2026
260a738
✨ Feat: 알림 화면 로딩 에러 UI 적용
chanho0908 May 14, 2026
b5b27e2
✨ Feat: 목표 관리 화면 로딩 에러 UI 적용
chanho0908 May 14, 2026
3dd013c
✨ Feat: 목표 편집 화면 로딩 에러 UI 적용
chanho0908 May 14, 2026
57769ba
✨ Feat: 홈 화면 로딩 에러 UI 적용
chanho0908 May 14, 2026
7a4ccb5
✨ Feat: 통계 상세 화면 로딩 에러 UI 적용
chanho0908 May 14, 2026
fff2472
♻️ Refactor: 로딩 상태 인터페이스 구조 정리
chanho0908 May 14, 2026
3caf494
🐛 Fix: 설정 계정 액션 로딩 오버레이 보완
chanho0908 May 14, 2026
9107352
🐛 Fix: 설정 화면 초기 요청 차단 수정
chanho0908 May 14, 2026
ece8e89
♻️ Refactor: BaseViewModel 내 reduceLoadableState 함수 포맷 수정
chanho0908 May 14, 2026
181c074
💄 Style: extension.kt 파일 끝에 빈 줄 추가
chanho0908 May 14, 2026
9675e73
💄 Style: extension.kt 파일 끝에 빈 줄 추가
chanho0908 May 15, 2026
b6cf64d
🐛 Fix: 온보딩 로딩 상태 경합과 초기 입력 덮어쓰기 수정
chanho0908 May 15, 2026
ddb4e32
🐛 Fix: 알림 읽음 처리 코루틴 중첩 제거
chanho0908 May 21, 2026
c3a4660
🐛 Fix: 알림 초기 목록 요청 차단 수정
chanho0908 May 21, 2026
e2b7806
✨ Feat: 로딩 상태 없는 결과 처리 헬퍼 추가
chanho0908 May 21, 2026
648d972
🐛 Fix: 알림 읽음 처리 로딩 상태 갱신 방지
chanho0908 May 21, 2026
fbc525b
🐛 Fix: Update `hasLoadedContent` to require both in-progress and comp…
chanho0908 May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,7 @@ lint/tmp/
/public/.well-known
.firebase/
.claude
.codex
.agents
AGENTS.md
CLAUDE.md
7 changes: 7 additions & 0 deletions core/result/src/main/java/com/twix/result/Extension.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.twix.result

fun <T> AppResult<T>.errorOrNull() =
when (this) {
is AppResult.Error -> error
is AppResult.Success -> null
}
57 changes: 41 additions & 16 deletions core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ abstract class BaseViewModel<S : State, I : Intent, SE : SideEffect>(
/**
* 서버 통신 메서드 호출 및 응답을 처리하는 헬퍼 메서드
*
* LoadableState를 구현한 경우 자동으로 isLoading과 error를 업데이트한다.
* DefaultLoadableState를 구현한 경우 자동으로 isLoading과 error를 업데이트한다.
* 일반 State를 구현한 경우 onStart/onFinally로 화면별 로딩 상태를 직접 관리해야 한다.
*
* ## 에러 처리 가이드라인
*
* ### 1. 데이터 로딩 (초기 로드, 화면 진입 시)
* - LoadableState 구현 시 자동으로 error 상태 업데이트
* - DefaultLoadableState 구현 시 자동으로 error 상태 업데이트
* - `onError = null` 또는 추가 로직만 처리
* - 사용 예: 화면 진입 시 데이터 fetch, 리스트 초기 로드
*
Expand Down Expand Up @@ -140,43 +140,58 @@ abstract class BaseViewModel<S : State, I : Intent, SE : SideEffect>(

/**
* 에러 초기화
* 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 <D> handleResultWithoutLoadableStateUpdate(
result: AppResult<D>,
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)
}
}
}

Expand All @@ -191,7 +206,7 @@ abstract class BaseViewModel<S : State, I : Intent, SE : SideEffect>(
when (result) {
is AppResult.Success -> onSuccess(result.data)
is AppResult.Error -> {
// 공통 처리: 로깅 및 LoadableState 에러 업데이트
// 공통 처리: 로깅 및 DefaultLoadableState 에러 업데이트
handleError(result.error)
updateError(result.error)
// 메서드별 처리: 특정 화면만의 UX ex) 다이얼로그/토스트
Expand All @@ -200,6 +215,16 @@ abstract class BaseViewModel<S : State, I : Intent, SE : SideEffect>(
}
}

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 처리 중 발생한 예외
* */
Expand Down
29 changes: 29 additions & 0 deletions core/ui/src/main/java/com/twix/ui/base/ContentLoadableState.kt
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions core/ui/src/main/java/com/twix/ui/base/DefaultLoadableState.kt
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 0 additions & 20 deletions core/ui/src/main/java/com/twix/ui/base/LoadableState.kt

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) },
Expand All @@ -132,6 +135,7 @@ fun GoalEditorScreen(
uiState: GoalEditorUiState,
isEdit: Boolean = false,
onBack: () -> Unit,
onRetry: () -> Unit,
onCommitTitle: (String) -> Unit,
onSelectRepeatType: (RepeatCycle) -> Unit,
onCommitIcon: (GoalIconType) -> Unit,
Expand All @@ -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) }
Expand Down Expand Up @@ -506,6 +520,7 @@ private fun Preview() {
GoalEditorScreen(
uiState = uiState,
onBack = {},
onRetry = {},
onCommitTitle = {},
onSelectRepeatType = {},
onCommitEndDate = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
Expand Down Expand Up @@ -192,12 +193,6 @@ class GoalEditorViewModel(
onSuccess = { setGoal(it) },
onError = {
initializedGoalId = null
emitSideEffect(
GoalEditorSideEffect.ShowToast(
R.string.toast_goal_fetch_failed,
ToastType.ERROR,
),
)
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading