diff --git a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift index ab25b2ae..5ea89f0b 100644 --- a/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift +++ b/Projects/Feature/GoalDetail/Interface/Sources/GoalDetailReducer.swift @@ -93,6 +93,7 @@ public struct GoalDetailReducer { public var myHasEmoji: Bool { isFrontMyCard && selectedReactionEmoji != nil } public var isShowReactionBar: Bool { !isFrontMyCard && isCompleted } public var isLoading: Bool { item == nil } + public var isFetchFailed: Bool = false public var isEditing: Bool = false public var isSavingPhotoLog: Bool = false public var pendingEditedImageData: Data? @@ -145,6 +146,7 @@ public struct GoalDetailReducer { case dimmedBackgroundTapped case proofPhotoDismissed case cameraPermissionAlertDismissed + case dataRetryTapped } // MARK: - Internal diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift index 2de51a23..407f6e78 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift @@ -94,6 +94,7 @@ extension GoalDetailReducer { case .view(.onAppear): let date = state.verificationDate let goalId = state.goalId + state.isFetchFailed = false return .run { send in do { @@ -106,6 +107,9 @@ extension GoalDetailReducer { case .view(.onDisappear): return .none + + case .view(.dataRetryTapped): + return .send(.view(.onAppear)) // MARK: - Action case .view(.bottomButtonTapped): @@ -202,6 +206,7 @@ extension GoalDetailReducer { // MARK: - State Update case let .response(.fethedGoalDetailItem(item)): state.item = item + state.isFetchFailed = false if let goalIndex = state.completedGoalItems.firstIndex(where: { $0.myPhotoLog?.goalId == state.goalId || $0.yourPhotoLog?.goalId == state.goalId }) { @@ -216,7 +221,8 @@ extension GoalDetailReducer { return .none case .response(.fetchGoalDetailFailed): - return .send(.presentation(.showToast(.warning(message: "목표 상세 조회에 실패했어요")))) + state.isFetchFailed = true + return .none case let .response(.updateCurrentCardReaction(photoLogId: photoLogId, reaction: reaction)): guard let item = state.item else { return .none } diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 75bd83c3..9c8af088 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -118,24 +118,30 @@ private extension GoalDetailView { navigationBar .zIndex(1) - if store.item != nil { - cardView - .padding(.horizontal, 27) - .padding(.top, Constants.cardTopPadding) - - if store.isCompleted { - completedBottomContent - } else if store.currentCompletedGoal?.status != .completed { - bottomButton - .padding(.top, 105) - .overlay(alignment: .bottomLeading) { - pokeImage - .offset(x: 79, y: -45) - } + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else { + if store.item != nil { + cardView + .padding(.horizontal, 27) + .padding(.top, Constants.cardTopPadding) + + if store.isCompleted { + completedBottomContent + } else if store.currentCompletedGoal?.status != .completed { + bottomButton + .padding(.top, 105) + .overlay(alignment: .bottomLeading) { + pokeImage + .offset(x: 79, y: -45) + } + } } - } - Spacer() + Spacer() + } } } diff --git a/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift b/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift index 5c304f94..cc32e831 100644 --- a/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Goal/EditGoalListReducer.swift @@ -52,6 +52,7 @@ public struct EditGoalListReducer { public struct UIState: Equatable { public var isLoading: Bool = true + public var isFetchFailed: Bool = false public init() { } } @@ -73,6 +74,7 @@ public struct EditGoalListReducer { public var modal: TXModalStyle? { get { presentation.modal } set { presentation.modal = newValue } } public var toast: TXToastType? { get { presentation.toast } set { presentation.toast = newValue } } public var isLoading: Bool { get { ui.isLoading } set { ui.isLoading = newValue } } + public var isFetchFailed: Bool { get { ui.isFetchFailed } set { ui.isFetchFailed = newValue } } public var pendingGoalId: Int64? { get { data.pendingGoalId } set { data.pendingGoalId = newValue } } public var pendingAction: PendingAction? { get { data.pendingAction } set { data.pendingAction = newValue } } public var hasCards: Bool { !(cards?.isEmpty ?? true) } @@ -109,6 +111,7 @@ public struct EditGoalListReducer { case backgroundTapped case modalConfirmTapped case toastButtonTapped + case dataRetryTapped } // MARK: - Internal diff --git a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift index 439d2af5..dc0adf3f 100644 --- a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift @@ -52,6 +52,7 @@ public struct HomeReducer { public struct UIState: Equatable { public var isLoading: Bool = true + public var isFetchFailed: Bool = false public var mainTitle: String = "KEEPILUV" public var calendarMonthTitle: String = "" public var isRefreshHidden: Bool = true @@ -84,6 +85,10 @@ public struct HomeReducer { get { ui.isLoading } set { ui.isLoading = newValue } } + public var isFetchFailed: Bool { + get { ui.isFetchFailed } + set { ui.isFetchFailed = newValue } + } public var mainTitle: String { get { ui.mainTitle } set { ui.mainTitle = newValue } @@ -206,6 +211,7 @@ public struct HomeReducer { case proofPhotoDismissed case addGoalButtonTapped(GoalCategory) case cameraPermissionAlertDismissed + case dataRetryTapped case perfToastShowTapped case perfToastDismissTapped case perfCalendarNextTapped @@ -223,7 +229,7 @@ public struct HomeReducer { // MARK: - Response public enum Response { case fetchGoalsCompleted(GoalList, date: TXCalendarDate) - case fetchGoalsFailed + case fetchGoalsFailed(date: TXCalendarDate) case authorizationCompleted(id: Int64, isAuthorized: Bool) case deletePhotoLogCompleted(goalId: Int64) case deletePhotoLogFailed diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift index 354e625e..786a8242 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift @@ -157,6 +157,9 @@ extension EditGoalListReducer { case .view(.toastButtonTapped): return .send(.delegate(.goToCompletedStats)) + + case .view(.dataRetryTapped): + return .send(.internal(.fetchGoals)) // MARK: - Update State case let .internal(.setCalendarDate(date)): @@ -170,6 +173,7 @@ extension EditGoalListReducer { case .internal(.fetchGoals): state.isLoading = true + state.isFetchFailed = false let date = state.calendarDate return .run { send in do { @@ -201,6 +205,7 @@ extension EditGoalListReducer { return .none } state.isLoading = false + state.isFetchFailed = false state.editableGoals = goals let items = goals.map { GoalEditCardItem( @@ -238,6 +243,11 @@ extension EditGoalListReducer { state.isLoading = false state.pendingGoalId = nil state.pendingAction = nil + + if state.cards == nil { + state.isFetchFailed = true + return .none + } return .send(.presentation(.showToast(.warning(message: message)))) case let .presentation(.showToast(toast)): diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift index 89f46233..c14ab15f 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift @@ -21,20 +21,24 @@ struct EditGoalListView: View { navigationBar weekCalendar .padding(.top, 4) - if let cards = store.cards, !cards.isEmpty { - cardScrollView - .padding(.bottom, 1) + + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else if let cards = store.cards { + if cards.isEmpty { + emptyContent + } else { + cardScrollView + .padding(.bottom, 1) + } + } else { + Spacer() } - - Spacer() } .ignoresSafeArea(.container, edges: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay { - if let cards = store.cards, cards.isEmpty { - emptyContent - } - } .toolbar(.hidden, for: .navigationBar) .onAppear { store.send(.view(.onAppear)) diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 441c64d1..6dd24ef7 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -118,6 +118,9 @@ extension HomeReducer { case .view(.refreshPulled): return .send(.internal(.fetchGoals)) + case .view(.dataRetryTapped): + return .send(.internal(.fetchGoals)) + case .view(.perfToastShowTapped): return .send(.presentation(.showToast(.warning(message: "perf-toast")))) @@ -335,15 +338,18 @@ extension HomeReducer { } state.isLoading = false + state.isFetchFailed = false if state.items != items { state.items = items } return .none - case .response(.fetchGoalsFailed): + case let .response(.fetchGoalsFailed(date)): + guard date == state.calendarDate else { return .none } state.isLoading = false - return .send(.presentation(.showToast(.warning(message: "목표 조회에 실패했어요")))) + state.isFetchFailed = true + return .none case let .internal(.setCalendarDate(date)): guard date != state.calendarDate else { return .none } @@ -384,6 +390,7 @@ extension HomeReducer { } else { state.isLoading = true } + state.isFetchFailed = false return .run { send in // 읽지 않은 알림 여부 체크 if let hasUnread = try? await notificationClient.fetchUnread() { @@ -394,7 +401,7 @@ extension HomeReducer { let goalList = try await goalClient.fetchGoals(cacheKey) await send(.response(.fetchGoalsCompleted(goalList, date: date))) } catch { - await send(.response(.fetchGoalsFailed)) + await send(.response(.fetchGoalsFailed(date: date))) } } diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 5a69da0a..4d11a77f 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import FeatureHomeInterface +import SharedDesignSystem import SharedPerfTestingSupport /// 홈 화면을 렌더링하는 View입니다. @@ -61,10 +62,13 @@ public struct HomeView: View { } HomeNavigationBarSection(store: store) HomeCalendarSection(store: store) - // The branch reads `hasCards` / `isEmptyVisible` so it stays in - // the parent body. Both are cheap derived booleans. Items / - // headerRow read-set lives entirely inside the child sub-view. - if store.hasCards { + // The branch reads presentation booleans so it stays in the parent + // body. Each section owns the rest of its read-set. + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else if store.hasCards { HomeContentSection(store: store) } else if store.isEmptyVisible { HomeEmptyContentSection(store: store) diff --git a/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift b/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift index 5b1e9ad7..76f116ec 100644 --- a/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift +++ b/Projects/Feature/Notification/Interface/Sources/NotificationReducer.swift @@ -19,6 +19,7 @@ public struct NotificationReducer { public struct State: Equatable { public var notifications: IdentifiedArrayOf public var isLoading: Bool + public var isFetchFailed: Bool public var isLoadingMore: Bool public var hasNext: Bool public var lastId: Int64? @@ -26,12 +27,14 @@ public struct NotificationReducer { public init( notifications: IdentifiedArrayOf = [], isLoading: Bool = false, + isFetchFailed: Bool = false, isLoadingMore: Bool = false, hasNext: Bool = false, lastId: Int64? = nil ) { self.notifications = notifications self.isLoading = isLoading + self.isFetchFailed = isFetchFailed self.isLoadingMore = isLoadingMore self.hasNext = hasNext self.lastId = lastId @@ -48,6 +51,7 @@ public struct NotificationReducer { case backButtonTapped case notificationTapped(NotificationItem) case loadMore + case dataRetryTapped } // MARK: - Response diff --git a/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift b/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift index a926db4c..84b01950 100644 --- a/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift +++ b/Projects/Feature/Notification/Sources/NotificationReducer+Impl.swift @@ -63,6 +63,9 @@ private func reduceView( case .loadMore: return handleLoadMore(state: &state, notificationClient: notificationClient) + + case .dataRetryTapped: + return handleOnAppear(state: &state, notificationClient: notificationClient) } } @@ -75,6 +78,7 @@ private func reduceResponse( switch action { case .fetchListResponse(.success(let result)): state.isLoading = false + state.isFetchFailed = false state.notifications = IdentifiedArray( uniqueElements: result.notifications.map { NotificationItem(from: $0) } ) @@ -84,6 +88,7 @@ private func reduceResponse( case .fetchListResponse(.failure): state.isLoading = false + state.isFetchFailed = true return .none case .fetchMoreResponse(.success(let result)): @@ -117,6 +122,7 @@ private func handleOnAppear( ) -> Effect { guard !state.isLoading else { return .none } state.isLoading = true + state.isFetchFailed = false return .run { send in do { let result = try await notificationClient.fetchList(nil, 10) diff --git a/Projects/Feature/Notification/Sources/NotificationView.swift b/Projects/Feature/Notification/Sources/NotificationView.swift index 5f5dbd77..2c536162 100644 --- a/Projects/Feature/Notification/Sources/NotificationView.swift +++ b/Projects/Feature/Notification/Sources/NotificationView.swift @@ -23,12 +23,19 @@ public struct NotificationView: View { VStack(spacing: 0) { navigationBar - ZStack { - if filteredNotifications.isEmpty { - emptyView - } else { - contentView + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) } + } else { + ZStack { + if filteredNotifications.isEmpty { + emptyView + } else { + contentView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } .ignoresSafeArea(.container, edges: .bottom) diff --git a/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift b/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift index baf028d6..87c66a31 100644 --- a/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift +++ b/Projects/Feature/Settings/Interface/Sources/SettingsReducer.swift @@ -34,12 +34,14 @@ public struct SettingsReducer { public var originalNickname: String public var isEditing: Bool public var isLoading: Bool + public var isProfileFetchFailed: Bool // Language public var selectedLanguage: TXLanguage // Account public var coupleCode: String + public var isCoupleCodeFetchFailed: Bool public var modal: TXModalStyle? public var modalPurpose: ModalPurpose? @@ -55,6 +57,7 @@ public struct SettingsReducer { public var isMarketingPushEnabled: Bool public var isNightMarketingPushEnabled: Bool public var isNotificationSettingsLoading: Bool + public var isNotificationSettingsFetchFailed: Bool public var isSystemNotificationEnabled: Bool public static let minLength = 2 @@ -100,8 +103,10 @@ public struct SettingsReducer { self.originalNickname = nickname self.isEditing = isEditing self.isLoading = false + self.isProfileFetchFailed = false self.selectedLanguage = selectedLanguage self.coupleCode = coupleCode + self.isCoupleCodeFetchFailed = false self.modalPurpose = nil self.appVersion = appVersion self.storeVersion = storeVersion @@ -109,6 +114,7 @@ public struct SettingsReducer { self.isMarketingPushEnabled = isMarketingPushEnabled self.isNightMarketingPushEnabled = isNightMarketingPushEnabled self.isNotificationSettingsLoading = false + self.isNotificationSettingsFetchFailed = false self.isSystemNotificationEnabled = true } } @@ -137,6 +143,8 @@ public struct SettingsReducer { case withdrawTapped case modalConfirmTapped case notificationSettingsOnAppear + case settingsDataRetryTapped + case notificationSettingsDataRetryTapped case pokePushToggled(Bool) case marketingPushToggled(Bool) case nightPushToggled(Bool) @@ -227,4 +235,8 @@ extension SettingsReducer.State { public var isNicknameValid: Bool { isNicknameLengthValid && !containsProfanity } + + public var isSettingsFetchFailed: Bool { + isProfileFetchFailed || isCoupleCodeFetchFailed + } } diff --git a/Projects/Feature/Settings/Sources/Account/AccountView.swift b/Projects/Feature/Settings/Sources/Account/AccountView.swift index c45dbf13..1f40dc7c 100644 --- a/Projects/Feature/Settings/Sources/Account/AccountView.swift +++ b/Projects/Feature/Settings/Sources/Account/AccountView.swift @@ -18,10 +18,16 @@ struct AccountView: View { VStack(spacing: 0) { navigationBar - ScrollView { - accountList - .padding(.top, Spacing.spacing8) - .padding(.horizontal, Spacing.spacing8) + if store.isSettingsFetchFailed { + DataRetryView { + store.send(.view(.settingsDataRetryTapped)) + } + } else { + ScrollView { + accountList + .padding(.top, Spacing.spacing8) + .padding(.horizontal, Spacing.spacing8) + } } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift b/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift index f2505195..2b983e75 100644 --- a/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift +++ b/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift @@ -19,14 +19,20 @@ struct NotificationSettingsView: View { VStack(spacing: 0) { navigationBar - ZStack { - if !store.isSystemNotificationEnabled { - disabledView - } else { - ScrollView { - notificationList - .padding(.top, Spacing.spacing8) - .padding(.horizontal, Spacing.spacing8) + if store.isNotificationSettingsFetchFailed { + DataRetryView { + store.send(.view(.notificationSettingsDataRetryTapped)) + } + } else { + ZStack { + if !store.isSystemNotificationEnabled { + disabledView + } else { + ScrollView { + notificationList + .padding(.top, Spacing.spacing8) + .padding(.horizontal, Spacing.spacing8) + } } } } diff --git a/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift b/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift index 1e8580ed..b1e09695 100644 --- a/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift +++ b/Projects/Feature/Settings/Sources/Settings/SettingsReducer+Impl.swift @@ -60,6 +60,8 @@ private func reduceCore( @Dependency(\.onboardingClient) var onboardingClient state.appVersion = AppVersionProvider.currentVersion + state.isProfileFetchFailed = false + state.isCoupleCodeFetchFailed = false return .merge( .run { send in let storeVersion = await AppVersionProvider.fetchStoreVersion() @@ -241,9 +243,13 @@ private func reduceCore( case .view(.notificationSettingTapped): return .send(.delegate(.navigateToNotificationSettings)) + case .view(.settingsDataRetryTapped): + return .send(.view(.onAppear)) + case .response(.fetchMyProfileResponse(.success(let name))): state.nickname = name state.originalNickname = name + state.isProfileFetchFailed = false return .none case .response(.fetchMyProfileResponse(.failure(let error))): @@ -251,10 +257,12 @@ private func reduceCore( networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } + state.isProfileFetchFailed = true return .none case .response(.fetchCoupleCodeResponse(.success(let coupleCode))): state.coupleCode = coupleCode + state.isCoupleCodeFetchFailed = false return .none case .response(.fetchCoupleCodeResponse(.failure(let error))): @@ -262,6 +270,7 @@ private func reduceCore( networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } + state.isCoupleCodeFetchFailed = true return .none case .response(.logoutResponse(.success)): @@ -320,6 +329,7 @@ private func reduceCore( } state.isNotificationSettingsLoading = true + state.isNotificationSettingsFetchFailed = false return .merge( checkPermissionEffect, .run { send in @@ -334,6 +344,7 @@ private func reduceCore( case .response(.fetchNotificationSettingsResponse(.success(let settings))): state.isNotificationSettingsLoading = false + state.isNotificationSettingsFetchFailed = false state.isPokePushEnabled = settings.isPushEnabled state.isMarketingPushEnabled = settings.isMarketingEnabled state.isNightMarketingPushEnabled = settings.isNightEnabled @@ -345,8 +356,12 @@ private func reduceCore( networkError == .authorizationError { return .send(.delegate(.sessionExpired)) } + state.isNotificationSettingsFetchFailed = true return .none + case .view(.notificationSettingsDataRetryTapped): + return .send(.view(.notificationSettingsOnAppear)) + case .view(.pokePushToggled(let enabled)): @Dependency(\.notificationClient) var notificationClient // 낙관적 업데이트 diff --git a/Projects/Feature/Settings/Sources/Settings/SettingsView.swift b/Projects/Feature/Settings/Sources/Settings/SettingsView.swift index d3644e7b..4f93deee 100644 --- a/Projects/Feature/Settings/Sources/Settings/SettingsView.swift +++ b/Projects/Feature/Settings/Sources/Settings/SettingsView.swift @@ -27,15 +27,21 @@ public struct SettingsView: View { VStack(spacing: 0) { navigationBar - ScrollView { - VStack(spacing: Spacing.spacing9) { - profileSection - .padding(.horizontal, Spacing.spacing8) - - settingsListSection - .padding(.horizontal, Spacing.spacing8) + if store.isSettingsFetchFailed { + DataRetryView { + store.send(.view(.settingsDataRetryTapped)) + } + } else { + ScrollView { + VStack(spacing: Spacing.spacing9) { + profileSection + .padding(.horizontal, Spacing.spacing8) + + settingsListSection + .padding(.horizontal, Spacing.spacing8) + } + .padding(.top, Spacing.spacing8) } - .padding(.top, Spacing.spacing8) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift index 7fdc9cf4..637d5e60 100644 --- a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift @@ -36,6 +36,8 @@ public struct StatsDetailReducer { public let goalId: Int64 public var isLoading: Bool = false + public var isCalendarFetchFailed: Bool = false + public var isSummaryFetchFailed: Bool = false public var isDropdownPresented: Bool = false public var selectedDropDownItem: GoalDropList? public var currentMonth: TXCalendarDate @@ -60,6 +62,7 @@ public struct StatsDetailReducer { } public var naviBarTitle: String { statsDetail?.goalName ?? "" } public var isCompleted: Bool { statsDetail?.isCompleted == true } + public var isFetchFailed: Bool { isCalendarFetchFailed || isSummaryFetchFailed } /// 통계 요약 영역의 단일 행 정보를 표현합니다. public struct StatsSummaryInfo: Equatable { @@ -113,6 +116,7 @@ public struct StatsDetailReducer { case dropDownSelected(GoalDropList) case backgroundTapped case modalConfirmTapped + case dataRetryTapped } // MARK: - Internal @@ -129,7 +133,7 @@ public struct StatsDetailReducer { // MARK: - Response public enum Response: Equatable { case fetchStatsDetailCalendarSuccess(StatsDetail, month: String) - case fetchStatsDetailCalendarFailed + case fetchStatsDetailCalendarFailed(month: String) case fetchStatsDetailSummarySuccess(StatsDetail.Summary) case fetchStatsDetailSummaryFailed case completeGoalSuccees diff --git a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift index 19239028..6e5e3951 100644 --- a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift @@ -33,6 +33,7 @@ public struct StatsReducer { public var currentMonth: TXCalendarDate = .init() public var monthTitle: String { currentMonth.formattedYearMonth } public var isLoading: Bool = false + public var isFetchFailed: Bool = false public var isOngoing: Bool = true public var isNextMonthDisabled: Bool { currentMonth >= TXCalendarDate() @@ -86,6 +87,7 @@ public struct StatsReducer { case statsCardTapped(goalId: Int64) case previousMonthTapped case nextMonthTapped + case dataRetryTapped } // MARK: - Internal @@ -95,8 +97,8 @@ public struct StatsReducer { // MARK: - Response public enum Response: Equatable { - case fetchedStats(stats: Stats, month: String) - case fetchStatsFailed + case fetchedStats(stats: Stats, month: String, isOngoing: Bool) + case fetchStatsFailed(month: String, isOngoing: Bool) } // MARK: - Presentation diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift index 835fcddf..c1332655 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailReducer+Impl.swift @@ -39,6 +39,12 @@ extension StatsDetailReducer { case .view(.onDisappear): return .none + + case .view(.dataRetryTapped): + return .merge( + .send(.internal(.fetchStatsDetailCalendar)), + .send(.internal(.fetchStatsDetailSummary)) + ) // MARK: - User Action case let .view(.navigationBarTapped(action)): @@ -163,9 +169,11 @@ extension StatsDetailReducer { var applyCached: Effect = .none if let cached = state.completedDateCache[month] { state.isLoading = false + state.isCalendarFetchFailed = false applyCached = .send(.internal(.updateMonthlyDate(cached))) } else { state.isLoading = true + state.isCalendarFetchFailed = false } let fetchRemote: Effect = .run { send in @@ -173,7 +181,7 @@ extension StatsDetailReducer { let statsDetail = try await statsClient.fetchStatsDetailCalendar(goalId, month) await send(.response(.fetchStatsDetailCalendarSuccess(statsDetail, month: month))) } catch { - await send(.response(.fetchStatsDetailCalendarFailed)) + await send(.response(.fetchStatsDetailCalendarFailed(month: month))) } } @@ -181,6 +189,7 @@ extension StatsDetailReducer { case .internal(.fetchStatsDetailSummary): let goalId = state.goalId + state.isSummaryFetchFailed = false return .run { send in do { let summary = try await statsClient.fetchStatsDetailSummary(goalId) @@ -192,6 +201,7 @@ extension StatsDetailReducer { case let .response(.fetchStatsDetailCalendarSuccess(statsDetail, month)): state.isLoading = false + state.isCalendarFetchFailed = false state.statsDetail = statsDetail state.completedDateCache[month] = statsDetail.completedDate.filter { $0.date.hasPrefix(month) } @@ -202,14 +212,18 @@ extension StatsDetailReducer { return .send(.internal(.updateMonthlyDate(state.completedDateCache[month] ?? []))) - case .response(.fetchStatsDetailCalendarFailed): + case let .response(.fetchStatsDetailCalendarFailed(month)): + guard month == state.currentMonth.formattedYearDashMonth else { return .none } state.isLoading = false + state.isCalendarFetchFailed = true return .none case let .response(.fetchStatsDetailSummarySuccess(summary)): + state.isSummaryFetchFailed = false return .send(.internal(.updateStatsSummary(summary))) case .response(.fetchStatsDetailSummaryFailed): + state.isSummaryFetchFailed = true return .none case .internal(.patchCompleteGoal): diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift index c49c6fb8..1f46690d 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift @@ -20,19 +20,25 @@ struct StatsDetailView: View { var body: some View { VStack(spacing: 0) { navigationBar - - ScrollView { - VStack(spacing: 0) { - monthNavigation - .padding(.top, 24) - calendar - .padding(.top, 12) - statsInfoContent - .padding(.top, 44) - - Spacer() + + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else { + ScrollView { + VStack(spacing: 0) { + monthNavigation + .padding(.top, 24) + calendar + .padding(.top, 12) + statsInfoContent + .padding(.top, 44) + + Spacer() + } + .padding(.horizontal, 20) } - .padding(.horizontal, 20) } } .background(Color.Gray.gray50) diff --git a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift index cf073353..88430640 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift @@ -47,6 +47,9 @@ extension StatsReducer { case .view(.nextMonthTapped): state.currentMonth.goToNextMonth() return .send(.internal(.fetchStats)) + + case .view(.dataRetryTapped): + return .send(.internal(.fetchStats)) case let .view(.statsCardTapped(goalId)): let date = state.isOngoing ? state.currentMonth : TXCalendarDate() @@ -66,21 +69,33 @@ extension StatsReducer { let cachedItems = state.ongoingItemsCache[month] { state.ongoingItems = cachedItems state.isLoading = false + state.isFetchFailed = false } else { state.isLoading = true + state.isFetchFailed = false } return .run { send in do { let stats = try await statsClient.fetchStats(month, isOngoing) - await send(.response(.fetchedStats(stats: stats, month: month))) + await send(.response(.fetchedStats( + stats: stats, + month: month, + isOngoing: isOngoing)) + ) } catch { - await send(.response(.fetchStatsFailed)) + await send(.response(.fetchStatsFailed(month: month, isOngoing: isOngoing))) } } - case let .response(.fetchedStats(stats, month)): + case let .response(.fetchedStats(stats, month, isOngoing)): + guard month == state.currentMonth.formattedYearDashMonth, + isOngoing == state.isOngoing else { + return .none + } + state.isLoading = false + state.isFetchFailed = false let items = stats.stats.map { let goalCount = $0.monthlyCount ?? $0.totalCount ?? 0 @@ -105,16 +120,11 @@ extension StatsReducer { ) } - if state.isOngoing { + if isOngoing { state.ongoingItemsCache[month] = items } - // 요청 시점의 탭/월과 현재 상태가 같을 때만 화면을 업데이트합니다. - guard month == state.currentMonth.formattedYearDashMonth else { - return .none - } - - if state.isOngoing { + if isOngoing { state.ongoingItems = items } else { state.completedItems = items @@ -122,9 +132,14 @@ extension StatsReducer { return .none - case .response(.fetchStatsFailed): + case let .response(.fetchStatsFailed(month, isOngoing)): + guard month == state.currentMonth.formattedYearDashMonth, + isOngoing == state.isOngoing else { + return .none + } state.isLoading = false - return .send(.presentation(.showToast(.warning(message: "통계 조회에 실패했어요")))) + state.isFetchFailed = true + return .none case .delegate: return .none diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index 2c072863..1ea9a743 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -19,19 +19,22 @@ struct StatsView: View { VStack(spacing: 0) { navigationBar topTabBar - - if let items = store.items, !items.isEmpty { - cardList + + if store.isFetchFailed { + DataRetryView { + store.send(.view(.dataRetryTapped)) + } + } else if let items = store.items { + if items.isEmpty { + statsEmptyView + } else { + cardList + } + } else { + Spacer() } - - Spacer() } .background(Color.Gray.gray50) - .overlay { - if let items = store.items, items.isEmpty { - statsEmptyView - } - } .onAppear { store.send(.view(.onAppear)) } .txToast(item: $store.toast) .toolbar(.hidden, for: .tabBar) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift index fe60c322..9792380d 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/Rect/TXRectButton.swift @@ -18,16 +18,16 @@ struct TXRectButton: View { .typography(style.typography ?? size.typhography) .foregroundStyle(state.fontColor) .padding(.horizontal, size.horizontalPadding) - .frame(minWidth: size.minWidth, maxWidth: size.maxWidth) - .frame(height: size.height) + .frame(minWidth: size.minWidth(style: style), maxWidth: size.maxWidth(style: style)) + .frame(height: size.height(style: style)) .background(state.backgroundColor) .insideBorder( state.borderColor, - shape: RoundedRectangle(cornerRadius: size.radius), + shape: RoundedRectangle(cornerRadius: size.radius(style: style)), lineWidth: state.borderWidth ) } - .clipShape(RoundedRectangle(cornerRadius: size.radius)) + .clipShape(RoundedRectangle(cornerRadius: size.radius(style: style))) .padding(.vertical, size.outVerticalPadding) .buttonStyle(.plain) } else { @@ -41,36 +41,44 @@ private extension TXButtonShape.TXRectStyle { var text: String { switch self { case .basic(let text, _): text + case .round(let text): text } } var typography: TypographyToken? { switch self { case .basic(_, let typography): typography + case .round: nil } } } private extension TXButtonShape.TXRectSize { - var maxWidth: CGFloat? { - switch self { - case .l: .infinity - case .m: 151 - case .s: nil + func maxWidth(style: TXButtonShape.TXRectStyle) -> CGFloat? { + switch (style, self) { + case (.basic, .l): .infinity + case (.basic, .m): 151 + case (.basic, .s): nil + case (.round, .s): .infinity + case (.round, .m), (.round, .l): nil } } - var minWidth: CGFloat? { - switch self { - case .l, .m: nil - case .s: 56 + func minWidth(style: TXButtonShape.TXRectStyle) -> CGFloat? { + switch (style, self) { + case (.basic, .l), (.basic, .m): nil + case (.basic, .s): 56 + case (.round, .s): 100 + case (.round, .m), (.round, .l): nil } } - var height: CGFloat { - switch self { - case .l, .m: 52 - case .s: 32 + func height(style: TXButtonShape.TXRectStyle) -> CGFloat? { + switch (style, self) { + case (.basic, .l), (.basic, .m): 52 + case (.basic, .s): 32 + case (.round, .s): 42 + case (.round, .m), (.round, .l): nil } } @@ -81,13 +89,15 @@ private extension TXButtonShape.TXRectSize { } } - var radius: CGFloat { - switch self { - case .l, .m: Radius.s - case .s: Radius.xs + func radius(style: TXButtonShape.TXRectStyle) -> CGFloat { + switch (style, self) { + case (.basic, .l), (.basic, .m): Radius.s + case (.basic, .s): Radius.xs + case (.round, .s): 999 + case (.round, .m), (.round, .l): .zero } } - + var horizontalPadding: CGFloat { switch self { case .s: Spacing.spacing6 diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift index ed274687..b0dc32f8 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButtonShape.swift @@ -48,6 +48,7 @@ public enum TXButtonShape { /// 사각형 버튼의 표시 방식을 정의하는 타입입니다. public enum TXRectStyle { case basic(text: String, typography: TypographyToken? = nil) + case round(text: String) } /// 사각형 버튼의 크기 단계를 정의하는 타입입니다. diff --git a/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift b/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift new file mode 100644 index 00000000..c36955e7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift @@ -0,0 +1,94 @@ +// +// DataRetryView.swift +// SharedDesignSystem +// +// Created by 정지훈 on 6/5/26. +// + +import SwiftUI + +/// 데이터 로드 실패 상태에서 재시도 안내를 표시하는 View입니다. +/// +/// ## 사용 예시 +/// ```swift +/// DataRetryView { +/// store.send(.view(.dataRetryTapped)) +/// } +/// ``` +public struct DataRetryView: View { + var onTap: () -> Void + + /// `DataRetryView`를 생성합니다. + /// + /// ## 사용 예시 + /// ```swift + /// DataRetryView { + /// store.send(.view(.dataRetryTapped)) + /// } + /// ``` + /// + /// - Parameter onTap: 재시도 버튼을 탭했을 때 실행할 동작입니다. + public init(onTap: @escaping () -> Void) { + self.onTap = onTap + } + + public var body: some View { + GeometryReader { proxy in + VStack(spacing: 0) { + Image.Illustration.trash + + Text(Constants.title) + .typography(.t2_16b) + .padding(.top, Spacing.spacing5) + + Text(Constants.subTitle) + .typography(.c1_12r) + .foregroundStyle(Color.Gray.gray300) + .padding(.top, Spacing.spacing3) + + TXButton( + shape: .rect( + style: .round(text: Constants.buttonTitle), + size: .s, + state: .standard + ), + onTap: onTap + ) + .padding(.top, Spacing.spacing8) + } + .frame(width: Constants.frameWidth) + .position( + x: proxy.size.width / 2, + y: proxy.deviceCenterYInView + ) + .frame(width: proxy.size.width, height: proxy.size.height) + .background(Color.Common.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private extension GeometryProxy { + var deviceCenterYInView: CGFloat { + let frame = frame(in: .global) + let deviceCenterY = UIScreen.main.bounds.height / 2 + + return min( + max(0, deviceCenterY - frame.minY), + size.height + ) + } +} + +private extension DataRetryView { + enum Constants { + static let title: String = "데이터를 불러오지 못했어요" + static let subTitle: String = "잠시 후 다시 시도해 주세요" + static let buttonTitle: String = "재시도" + static let frameWidth: CGFloat = 212 + } +} + +#Preview { + DataRetryView(onTap: { }) +}