From f5bb5825cb6c74ab2d3b5201dbb841f72ebee2a6 Mon Sep 17 00:00:00 2001 From: jihun Date: Tue, 2 Jun 2026 20:36:32 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=EC=83=B7=20?= =?UTF-8?q?=EC=83=81=ED=95=98=20=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-?= =?UTF-8?q?=20#337?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Detail/GoalDetailView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index e329fdb3..f748f95f 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -188,8 +188,22 @@ private extension GoalDetailView { cardOffset = repeatedCardOffset(for: width) isCrossingDuringDrag = shouldCrossCards(for: width) } - .onEnded { _ in + .onEnded { value in guard !store.isEditing else { return } + + let translation = value.translation + let width = resistedDragWidth( + for: translation.width, + velocity: value.velocity.width + ) + + guard abs(width) >= abs(translation.height) else { + withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { + resetDragState() + } + return + } + withAnimation(.spring(response: 0.2, dampingFraction: 0.94)) { resetDragState() store.send(.view(.cardSwiped)) From 8c82d677e82c814f526fc05571d4143639e9ce24 Mon Sep 17 00:00:00 2001 From: jihun Date: Thu, 4 Jun 2026 14:06:56 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix:=20disable=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?-=20#337?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Button/Round/TXRoundButton.swift | 10 +++++++++- .../Sources/Components/Button/TXButton.swift | 4 ++++ .../Sources/Components/Card/Goal/GoalCardView.swift | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift index ea895461..0266469e 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift @@ -9,12 +9,20 @@ import SwiftUI struct TXRoundButton: View { let shape: TXButtonShape + let allowsActionWhenDisabled: Bool let onTap: () -> Void public var body: some View { if case let .round(style, size, state) = shape { Button { - onTap() + switch state { + case .disabled: + if allowsActionWhenDisabled { + onTap() + } + case .standard: + onTap() + } } label: { ZStack { Capsule() diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift index 6af90249..cf8d27e7 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/TXButton.swift @@ -24,6 +24,7 @@ import SwiftUI /// ``` public struct TXButton: View { let shape: TXButtonShape + let allowsActionWhenDisabled: Bool let onTap: () -> Void /// 버튼을 생성합니다. @@ -41,9 +42,11 @@ public struct TXButton: View { /// ``` public init( shape: TXButtonShape, + allowsActionWhenDisabled: Bool = false, onTap: @escaping () -> Void ) { self.shape = shape + self.allowsActionWhenDisabled = allowsActionWhenDisabled self.onTap = onTap } @@ -65,6 +68,7 @@ public struct TXButton: View { case .round: TXRoundButton( shape: shape, + allowsActionWhenDisabled: allowsActionWhenDisabled, onTap: onTap ) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift index 524cb235..3bddb7d2 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift @@ -169,6 +169,7 @@ private extension GoalCardView { size: .s, state: isButtonDisabled ? .disabled : .standard ), + allowsActionWhenDisabled: true, onTap: { buttonAction?() } ) .padding(.bottom, 14) From a3bb2ab60caf10ac5ef007ed80a63ec6e3dff083 Mon Sep 17 00:00:00 2001 From: jihun Date: Thu, 4 Jun 2026 14:09:28 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix:=20dropdownButton=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A3=BC=EC=9E=85=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20#337?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MakeGoal/Sources/MakeGoalView.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift index 147d9888..7f94a323 100644 --- a/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift +++ b/Projects/Feature/MakeGoal/Sources/MakeGoalView.swift @@ -177,7 +177,9 @@ private extension MakeGoalView { Spacer() if store.showPeriodCount { - dropDownButton { store.send(.view(.periodSelected)) } + dropDownButton(text: store.periodCountText) { + store.send(.view(.periodSelected)) + } } } } @@ -190,7 +192,9 @@ private extension MakeGoalView { Spacer() - dropDownButton { store.send(.view(.startDateTapped)) } + dropDownButton(text: store.startDateText) { + store.send(.view(.startDateTapped)) + } } .frame(height: 32) .padding(.vertical, 16) @@ -214,7 +218,9 @@ private extension MakeGoalView { Spacer() - dropDownButton { store.send(.view(.endDateTapped)) } + dropDownButton(text: store.endDateText) { + store.send(.view(.endDateTapped)) + } } .padding(.vertical, 21.5) } @@ -236,9 +242,12 @@ private extension MakeGoalView { .padding(.vertical, -1) } - func dropDownButton(_ action: @escaping () -> Void) -> some View { + func dropDownButton( + text: String, + action: @escaping () -> Void + ) -> some View { HStack(spacing: 0) { - Text(store.startDateText) + Text(text) .typography(.b2_14r) .foregroundStyle(Color.Gray.gray500) Image.Icon.Symbol.arrow2Down From c9ff5f47ff8783d4c6c5d0181ee8e2a852df3710 Mon Sep 17 00:00:00 2001 From: jihun Date: Thu, 4 Jun 2026 14:13:39 +0900 Subject: [PATCH 04/16] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=EC=83=B7=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98=20=EC=8B=9C=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EC=9C=84=EC=B9=98=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=BD=94=EB=A9=98=ED=8A=B8=20offset=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-?= =?UTF-8?q?=20#337?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index f748f95f..75bd83c3 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -445,7 +445,7 @@ private extension GoalDetailView { .padding(.bottom, 26) .frame(width: rectFrame.width, height: rectFrame.height, alignment: .bottom) .rotationEffect(frontCardRotation) - .offset(x: posX, y: posY - keyboardInset) + .offset(x: posX + cardOffset, y: posY - keyboardInset) .animation(.easeOut(duration: 0.25), value: keyboardInset) } } From 117147ac94f43e86a69efe4bf0dececd7d7e2709 Mon Sep 17 00:00:00 2001 From: jihun Date: Thu, 4 Jun 2026 14:31:20 +0900 Subject: [PATCH 05/16] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=84=A4=EB=B9=84=EB=B0=94=20=EC=95=88=EC=9E=98?= =?UTF-8?q?=EB=A6=AC=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-=20#338?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/Stats/Sources/Detail/StatsDetailView.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift index 92f52b5c..0ce3b6df 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift @@ -156,8 +156,7 @@ private extension StatsDetailView { summaryTitle(for: summary.title) summartyContent(content: summary.content, isCompletedCount: summary.isCompletedCount) .layoutPriority(1) - - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -188,7 +187,6 @@ private extension StatsDetailView { Text(content[0]) .typography(.b4_12b) .foregroundStyle(Color.Gray.gray500) - .lineLimit(1) if isCompletedCount { Text("|") @@ -199,11 +197,9 @@ private extension StatsDetailView { Text(content[1]) .typography(.b4_12b) .foregroundStyle(Color.Gray.gray500) - .lineLimit(1) } } - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder From b64e23e69819429fc9c5e54936aa12f1c5631938 Mon Sep 17 00:00:00 2001 From: jihun Date: Thu, 4 Jun 2026 20:10:23 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20=ED=99=88=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=20=EB=95=8C=20UI=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?#339=20-=20emptyView=20y=EC=B6=95=20=EA=B0=80=EC=9A=B4=EB=8D=B0?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20-=20=EC=B2=AB=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20=ED=99=94=EC=82=B4=ED=91=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/HomeEmptyContentSection.swift | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift b/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift index 6212798f..e056ef9b 100644 --- a/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift +++ b/Projects/Feature/Home/Sources/Home/HomeEmptyContentSection.swift @@ -10,42 +10,34 @@ import FeatureHomeInterface import SharedDesignSystem /// `hadFirstGoal`을 읽는 빈 상태 영역입니다. -/// `emptyScrollHeight`는 로컬 `@State`로 관리해 다른 콘텐츠 section 재렌더링에 의해 -/// 초기화되지 않게 합니다. +/// `goalEmptyView`의 center anchor를 기기 화면 기준 y축 중앙에 배치합니다. struct HomeEmptyContentSection: View { let store: StoreOf - @State private var emptyScrollHeight: CGFloat = 0 - var body: some View { VStack(spacing: 0) { - HomeHeaderRow(store: store) - .padding(.horizontal, 20) - .padding(.top, 16) + GeometryReader { geo in + let frame = geo.frame(in: .global) + let deviceHeight = UIScreen.main.bounds.height + let deviceCenterYInSection = max(0, deviceHeight / 2 - frame.minY) - ScrollView { - goalEmptyView - // 실제 가시 영역 기준으로 중앙 정렬되도록 탭바 높이만큼 차감 - .frame(maxWidth: .infinity, minHeight: max(0, emptyScrollHeight - 58)) - .padding(.bottom, 58) - } - .scrollIndicators(.hidden) - .refreshable { - store.send(.view(.refreshPulled)) - } - .overlay(alignment: .bottomTrailing) { - emptyArrow - } - .frame(maxHeight: .infinity) - .background { - GeometryReader { geo in - Color.clear - .onAppear { emptyScrollHeight = geo.size.height } - .onChange(of: geo.size.height) { _, newValue in - emptyScrollHeight = newValue - } + ScrollView { + goalEmptyView + .frame(width: geo.size.width) + .position(x: geo.size.width / 2, y: deviceCenterYInSection) + } + .scrollIndicators(.hidden) + .refreshable { + store.send(.view(.refreshPulled)) } + .overlay(alignment: .bottomTrailing) { + if store.hadFirstGoal == false { + emptyArrow + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } + .frame(maxHeight: .infinity) } } @@ -79,12 +71,11 @@ struct HomeEmptyContentSection: View { } } } - .frame(maxWidth: .infinity, maxHeight: .infinity) } var emptyArrow: some View { Image.Illustration.arrow - .padding(.bottom, 71 + 58) + .padding(.bottom, 71 + TXTabBarLayout.height) .padding(.trailing, 86) .ignoresSafeArea() } From c74ef33adb32cbe0c2409742e9770b4353d5d047 Mon Sep 17 00:00:00 2001 From: jihun Date: Thu, 4 Jun 2026 21:20:03 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix:=20TXRoundButton=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20UI=20=EC=88=98=EC=A0=95=20-=20#339?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Button/Round/TXRoundButton.swift | 28 ++++++------------- .../Components/Card/Goal/GoalCardView.swift | 2 +- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift index 0266469e..b72bdaf1 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/Round/TXRoundButton.swift @@ -24,12 +24,12 @@ struct TXRoundButton: View { onTap() } } label: { - ZStack { + ZStack(alignment: .top) { Capsule() .fill(style.backgroundColor(state: state)) .frame(maxWidth: size.frameWidth) - .frame(height: size.backgroundHeight(state: state)) - .padding(.top, size.bottomYOffset(state: state)) + .frame(height: size.backgroundHeight) + .padding(.top, size.backgroundYOffset) Text(style.text) .typography(size.typography) @@ -42,8 +42,8 @@ struct TXRoundButton: View { lineWidth: size.borderWidth ) .background(style.foregroundColor(state: state), in: .capsule) + .padding(.top, size.foregroundYOffset(state: state)) } - .padding(.top, size.topYOffset(state: state)) } .buttonStyle(.plain) } else { @@ -120,29 +120,19 @@ private extension TXButtonShape.TXRoundSize { } } - func backgroundHeight(state: TXButtonShape.TXRoundState) -> CGFloat { + var backgroundHeight: CGFloat { switch self { case .l, .m: 70 - case .s: - switch state { - case .standard: 31 - case .disabled: 28 - } + case .s: 28 } } - func bottomYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { - switch self { - case .s, .l, .m: - switch state { - case .standard: 4 - case .disabled: 1 - } - } + var backgroundYOffset: CGFloat { + 4 } - func topYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { + func foregroundYOffset(state: TXButtonShape.TXRoundState) -> CGFloat { switch self { case .s, .l, .m: switch state { diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift index 3bddb7d2..ae498178 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/Goal/GoalCardView.swift @@ -180,7 +180,7 @@ private extension GoalCardView { .padding(.bottom, 10) } } - .frame(maxHeight: .infinity) + .frame(maxHeight: .infinity, alignment: .center) } func emojiImage(emoji: Image) -> some View { From a2afb1be9fb65cda1226ddfb1810461be473ba77 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Jun 2026 16:40:23 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix:=20=ED=99=88,=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=86=B5=EA=B3=84=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=B7=B0=20=EC=A7=84=EC=9E=85=EC=8B=9C=20=EB=B3=B4=EA=B3=A0?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EC=9B=94=EC=97=90=20=ED=95=B4=EB=8B=B9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B3=B4?= =?UTF-8?q?=EC=97=AC=EC=A3=BC=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-?= =?UTF-8?q?=20#350?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/Home/Interface/Sources/Home/HomeReducer.swift | 2 +- Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift | 2 +- Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift | 4 ++-- .../Stats/Interface/Sources/Detail/StatsDetailReducer.swift | 4 ++-- .../Feature/Stats/Interface/Sources/Stats/StatsReducer.swift | 2 +- .../Stats/Sources/Coordinator/StatsCoordinator+Impl.swift | 4 ++-- Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift | 2 +- Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift | 3 ++- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift index a9a47266..439d2af5 100644 --- a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift @@ -241,7 +241,7 @@ public struct HomeReducer { /// 홈 화면에서 외부로 전달하는 이벤트입니다. public enum Delegate { case goToGoalDetail(id: Int64, owner: GoalDetail.Owner, verificationDate: String) - case goToStatsDetail(id: Int64) + case goToStatsDetail(id: Int64, calendarDate: TXCalendarDate) case goToMakeGoal(GoalCategory) case goToEditGoalList(date: TXCalendarDate) case goToSettings diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 14df2ed1..89bc05da 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -286,7 +286,7 @@ extension HomeReducer { return .send(.delegate(.goToGoalDetail(id: card.id, owner: .mySelf, verificationDate: verificationDate))) case let .view(.headerTapped(card)): - return .send(.delegate(.goToStatsDetail(id: card.id))) + return .send(.delegate(.goToStatsDetail(id: card.id, calendarDate: state.calendarDate))) case .view(.floatingButtonTapped): state.isAddGoalPresented = true diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift index 813e71b7..8b877a91 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinator+Impl.swift @@ -58,9 +58,9 @@ extension HomeCoordinator { state.notification = .init() return .none - case let .home(.delegate(.goToStatsDetail(id))): + case let .home(.delegate(.goToStatsDetail(id, date))): state.routes.append(.statsDetail) - state.statsDetail = .init(goalId: id) + state.statsDetail = .init(goalId: id, calendarDate: date) return .none case .statsDetail(.delegate(.navigateBack)): diff --git a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift index 72fee817..031dc1f7 100644 --- a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift @@ -80,10 +80,10 @@ public struct StatsDetailReducer { /// ```swift /// let state = StatsDetailReducer.State(goalId: 1) /// ``` - public init(goalId: Int64) { + public init(goalId: Int64, calendarDate: TXCalendarDate?) { self.goalId = goalId - let currentMonth = TXCalendarDate() + let currentMonth = calendarDate ?? TXCalendarDate() self.currentMonth = currentMonth self.monthlyData = TXCalendarDataGenerator.generateMonthData( for: currentMonth, diff --git a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift index 58e21b3f..19239028 100644 --- a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift @@ -109,7 +109,7 @@ public struct StatsReducer { /// StatsReducer가 상위 Coordinator로 전달하는 이벤트입니다. public enum Delegate { - case goToStatsDetail(goalId: Int64) + case goToStatsDetail(goalId: Int64, calendarDate: TXCalendarDate) } case view(View) diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift index 0b284b33..a98f3e91 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinator+Impl.swift @@ -33,9 +33,9 @@ extension StatsCoordinator { let reducer = Reduce { state, action in switch action { // MARK: - Child Action - case let .stats(.delegate(.goToStatsDetail(goalId))): + case let .stats(.delegate(.goToStatsDetail(goalId, calendarDate))): state.routes.append(.statsDetail) - state.statsDetail = .init(goalId: goalId) + state.statsDetail = .init(goalId: goalId, calendarDate: calendarDate) return .none case let .statsDetail(.delegate(.goToGoalDetail(goalId, isCompletedPartner, date))): diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift index 0ce3b6df..3872b5ed 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift @@ -262,7 +262,7 @@ private extension StatsDetailView { #Preview { StatsDetailView( store: Store( - initialState: StatsDetailReducer.State(goalId: 1), + initialState: StatsDetailReducer.State(goalId: 1, calendarDate: nil), reducer: { StatsDetailReducer() } ) ) diff --git a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift index 349dac0a..cf073353 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift @@ -49,7 +49,8 @@ extension StatsReducer { return .send(.internal(.fetchStats)) case let .view(.statsCardTapped(goalId)): - return .send(.delegate(.goToStatsDetail(goalId: goalId))) + let date = state.isOngoing ? state.currentMonth : TXCalendarDate() + return .send(.delegate(.goToStatsDetail(goalId: goalId, calendarDate: date))) // MARK: - Update State case let .presentation(.showToast(toast)): From a2352f625fedd8074ff00c8e94f68e4967dddd2d Mon Sep 17 00:00:00 2001 From: jihun Date: Mon, 8 Jun 2026 18:46:54 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix:=20empty=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20#350?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Feature/Stats/Sources/Stats/StatsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index b5fa40fb..2c072863 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -114,7 +114,7 @@ private extension StatsView { if store.isOngoing { VStack(spacing: 8) { Image.Illustration.scare - Text("아직 목표가 없어요!") + Text("이 달은 목표가 없어요!") .typography(.t2_16b) .foregroundStyle(Color.Gray.gray400) } From 2cffae4febf9450811244c93d0d1779c1961c412 Mon Sep 17 00:00:00 2001 From: jihun Date: Mon, 8 Jun 2026 21:20:36 +0900 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20CI=20Fastlane=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20-=20#350?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 7a118b49..b2a45d3e 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,4 @@ source "https://rubygems.org" gem "fastlane" +gem "multi_json" From 931c4e71728df6c27ec97f875bfa1d38321e3f12 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Jun 2026 17:39:11 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20TXRectButton=20=EC=83=88=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20-=20#351?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Button/Rect/TXRectButton.swift | 54 +++++++++++-------- .../Components/Button/TXButtonShape.swift | 1 + 2 files changed, 33 insertions(+), 22 deletions(-) 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) } /// 사각형 버튼의 크기 단계를 정의하는 타입입니다. From 7d147cb4a34f8fcdfc304110fb2fe92e8635aaf9 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Jun 2026 17:39:24 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20DataRetryView=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20#351?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/View/DataRetryView.swift | 73 +++++++++++++++++++ .../Sources/Modifiers/View+TxDataRetry.swift | 56 ++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift create mode 100644 Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift 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..3607d9b8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift @@ -0,0 +1,73 @@ +// +// 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 { + 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) + } +} + +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: { }) +} diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift new file mode 100644 index 00000000..3e9fa8d7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift @@ -0,0 +1,56 @@ +// +// View+TxDataRetry.swift +// SharedDesignSystem +// +// Created by 정지훈 on 6/8/26. +// + +import SwiftUI + +public extension View { + /// 데이터 로드 실패 상태에서 재시도 안내 View를 overlay로 표시합니다. + /// + /// ## 사용 예시 + /// ```swift + /// content + /// .txDataRetry( + /// isPresented: store.isFetchFailed, + /// onRetry: { store.send(.view(.dataRetryTapped)) } + /// ) + /// ``` + /// + /// - Parameters: + /// - isPresented: 재시도 안내 View 표시 여부입니다. + /// - onRetry: 재시도 버튼을 탭했을 때 실행할 동작입니다. + func txDataRetry( + isPresented: Bool, + onRetry: @escaping () -> Void + ) -> some View { + modifier( + TXDataRetryModifier( + isPresented: isPresented, + onRetry: onRetry + ) + ) + } +} + +private struct TXDataRetryModifier: ViewModifier { + let isPresented: Bool + let onRetry: () -> Void + + func body(content: Content) -> some View { + content + .overlay { + GeometryReader { proxy in + if isPresented { + DataRetryView(onTap: onRetry) + .position( + x: proxy.size.width / 2, + y: proxy.size.height / 2 + ) + } + } + } + } +} From 5f2521195c8367f1982612f6a4f11c42feb1fc91 Mon Sep 17 00:00:00 2001 From: jihun Date: Mon, 8 Jun 2026 15:08:43 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=99=94=EB=A9=B4=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20Feature=EC=97=90=20=EC=A0=81=EC=9A=A9=20-=20#351?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interface/Sources/GoalDetailReducer.swift | 2 ++ .../Detail/GoalDetailReducer+Impl.swift | 6 ++++++ .../Sources/Detail/GoalDetailView.swift | 4 ++++ .../Sources/Goal/EditGoalListReducer.swift | 3 +++ .../Interface/Sources/Home/HomeReducer.swift | 8 +++++++- .../Goal/EditGoalListReducer+Impl.swift | 8 ++++++++ .../Home/Sources/Goal/EditGoalListView.swift | 4 ++++ .../Home/Sources/Home/HomeReducer+Impl.swift | 11 +++++++++-- .../Feature/Home/Sources/Home/HomeView.swift | 5 +++++ .../Sources/NotificationReducer.swift | 4 ++++ .../Sources/NotificationReducer+Impl.swift | 6 ++++++ .../Sources/NotificationView.swift | 5 +++++ .../Interface/Sources/SettingsReducer.swift | 12 ++++++++++++ .../Settings/Sources/Account/AccountView.swift | 4 ++++ .../NotificationSettingsView.swift | 4 ++++ .../Settings/SettingsReducer+Impl.swift | 15 +++++++++++++++ .../Sources/Settings/SettingsView.swift | 4 ++++ .../Sources/Detail/StatsDetailReducer.swift | 6 +++++- .../Interface/Sources/Stats/StatsReducer.swift | 4 +++- .../Detail/StatsDetailReducer+Impl.swift | 18 ++++++++++++++++-- .../Stats/Sources/Detail/StatsDetailView.swift | 6 +++++- .../Sources/Stats/StatsReducer+Impl.swift | 15 +++++++++++++-- .../Stats/Sources/Stats/StatsView.swift | 4 ++++ 23 files changed, 148 insertions(+), 10 deletions(-) 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..023334f8 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,6 +221,7 @@ extension GoalDetailReducer { return .none case .response(.fetchGoalDetailFailed): + state.isFetchFailed = true return .send(.presentation(.showToast(.warning(message: "목표 상세 조회에 실패했어요")))) case let .response(.updateCurrentCardReaction(photoLogId: photoLogId, reaction: reaction)): diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 75bd83c3..d19c87e3 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -107,6 +107,10 @@ public struct GoalDetailView: View { myEmojiFlyingReactionOverlay } .txToast(item: $store.toast, customPadding: 54) + .txDataRetry( + isPresented: store.isFetchFailed, + onRetry: { store.send(.view(.dataRetryTapped)) } + ) .txLoading(isPresented: store.isSavingPhotoLog) } } 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 0d2951ba..7f7743ff 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( @@ -236,6 +241,9 @@ extension EditGoalListReducer { case let .response(.apiError(message)): state.isLoading = false + if state.cards == nil { + state.isFetchFailed = true + } state.pendingGoalId = nil state.pendingAction = nil return .send(.presentation(.showToast(.warning(message: message)))) diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift index 89f46233..45720bf0 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift @@ -60,6 +60,10 @@ struct EditGoalListView: View { .txToast(item: $store.toast, onButtonTap: { store.send(.view(.toastButtonTapped)) }) + .txDataRetry( + isPresented: store.isFetchFailed, + onRetry: { store.send(.view(.dataRetryTapped)) } + ) .txLoading(isPresented: store.isLoading) } } diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 89bc05da..171b0188 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,14 +338,17 @@ 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 + state.isFetchFailed = true return .send(.presentation(.showToast(.warning(message: "목표 조회에 실패했어요")))) case let .internal(.setCalendarDate(date)): @@ -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..3890bb46 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입니다. @@ -74,6 +75,10 @@ public struct HomeView: View { .modifier(PerfToastPresentationHarness(toast: $store.presentation.toast)) .modifier(PerfHomeCounterMarkersHarness()) .modifier(HomePresentationLayer(store: store)) + .txDataRetry( + isPresented: store.isFetchFailed, + onRetry: { store.send(.view(.dataRetryTapped)) } + ) .onAppear { store.send(.view(.onAppear)) } 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..0d61827a 100644 --- a/Projects/Feature/Notification/Sources/NotificationView.swift +++ b/Projects/Feature/Notification/Sources/NotificationView.swift @@ -30,6 +30,7 @@ public struct NotificationView: View { contentView } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } .ignoresSafeArea(.container, edges: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -38,6 +39,10 @@ public struct NotificationView: View { store.send(.view(.onAppear)) } .toolbar(.hidden, for: .navigationBar) + .txDataRetry( + isPresented: store.isFetchFailed, + onRetry: { store.send(.view(.dataRetryTapped)) } + ) .txLoading(isPresented: store.isLoading && store.notifications.isEmpty) } } 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..afbbb0db 100644 --- a/Projects/Feature/Settings/Sources/Account/AccountView.swift +++ b/Projects/Feature/Settings/Sources/Account/AccountView.swift @@ -32,6 +32,10 @@ struct AccountView: View { store.send(.view(.modalConfirmTapped)) } } + .txDataRetry( + isPresented: store.isSettingsFetchFailed, + onRetry: { store.send(.view(.settingsDataRetryTapped)) } + ) .txLoading(isPresented: store.isLoading) } } diff --git a/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift b/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift index f2505195..4f41163e 100644 --- a/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift +++ b/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift @@ -42,6 +42,10 @@ struct NotificationSettingsView: View { store.send(.view(.notificationSettingsOnAppear)) } } + .txDataRetry( + isPresented: store.isNotificationSettingsFetchFailed, + onRetry: { store.send(.view(.notificationSettingsDataRetryTapped)) } + ) .txLoading(isPresented: store.isNotificationSettingsLoading) } } 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..f02e0122 100644 --- a/Projects/Feature/Settings/Sources/Settings/SettingsView.swift +++ b/Projects/Feature/Settings/Sources/Settings/SettingsView.swift @@ -62,6 +62,10 @@ public struct SettingsView: View { store.send(.view(.onAppear)) } .toolbar(.hidden, for: .navigationBar) + .txDataRetry( + isPresented: store.isSettingsFetchFailed, + onRetry: { store.send(.view(.settingsDataRetryTapped)) } + ) } private func dismissKeyboard() { diff --git a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift index 031dc1f7..96638857 100644 --- a/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Detail/StatsDetailReducer.swift @@ -33,6 +33,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 @@ -57,6 +59,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 { @@ -108,6 +111,7 @@ public struct StatsDetailReducer { case dropDownSelected(GoalDropList) case backgroundTapped case modalConfirmTapped + case dataRetryTapped } // MARK: - Internal @@ -124,7 +128,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..6da62a23 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 @@ -96,7 +98,7 @@ public struct StatsReducer { // MARK: - Response public enum Response: Equatable { case fetchedStats(stats: Stats, month: String) - case fetchStatsFailed + 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 3872b5ed..9a7af834 100644 --- a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift +++ b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift @@ -29,7 +29,7 @@ struct StatsDetailView: View { .padding(.top, 12) statsInfoContent .padding(.top, 44) - + Spacer() } .padding(.horizontal, 20) @@ -64,6 +64,10 @@ struct StatsDetailView: View { } } .txToast(item: $store.toast) + .txDataRetry( + isPresented: store.isFetchFailed, + onRetry: { store.send(.view(.dataRetryTapped)) } + ) .txLoading(isPresented: store.isLoading) } } diff --git a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift index cf073353..ab75b3d9 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,8 +69,10 @@ 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 @@ -75,12 +80,13 @@ extension StatsReducer { let stats = try await statsClient.fetchStats(month, isOngoing) await send(.response(.fetchedStats(stats: stats, month: month))) } catch { - await send(.response(.fetchStatsFailed)) + await send(.response(.fetchStatsFailed(month: month, isOngoing: isOngoing))) } } case let .response(.fetchedStats(stats, month)): state.isLoading = false + state.isFetchFailed = false let items = stats.stats.map { let goalCount = $0.monthlyCount ?? $0.totalCount ?? 0 @@ -122,8 +128,13 @@ 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 + state.isFetchFailed = true return .send(.presentation(.showToast(.warning(message: "통계 조회에 실패했어요")))) case .delegate: diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index 2c072863..003a698a 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -34,6 +34,10 @@ struct StatsView: View { } .onAppear { store.send(.view(.onAppear)) } .txToast(item: $store.toast) + .txDataRetry( + isPresented: store.isFetchFailed, + onRetry: { store.send(.view(.dataRetryTapped)) } + ) .toolbar(.hidden, for: .tabBar) } } From 29e1707023a27e4af9c414927f1b06dfe03de9a5 Mon Sep 17 00:00:00 2001 From: jihun Date: Mon, 8 Jun 2026 21:51:44 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20-=20#351?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift | 2 +- .../Home/Sources/Goal/EditGoalListReducer+Impl.swift | 6 ++++-- Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift | 2 +- .../Feature/Stats/Sources/Stats/StatsReducer+Impl.swift | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift index 023334f8..407f6e78 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailReducer+Impl.swift @@ -222,7 +222,7 @@ extension GoalDetailReducer { case .response(.fetchGoalDetailFailed): state.isFetchFailed = true - return .send(.presentation(.showToast(.warning(message: "목표 상세 조회에 실패했어요")))) + return .none case let .response(.updateCurrentCardReaction(photoLogId: photoLogId, reaction: reaction)): guard let item = state.item else { return .none } diff --git a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift index 7f7743ff..00a767ad 100644 --- a/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Goal/EditGoalListReducer+Impl.swift @@ -241,11 +241,13 @@ extension EditGoalListReducer { case let .response(.apiError(message)): state.isLoading = false + state.pendingGoalId = nil + state.pendingAction = nil + if state.cards == nil { state.isFetchFailed = true + return .none } - state.pendingGoalId = nil - state.pendingAction = nil return .send(.presentation(.showToast(.warning(message: message)))) case let .presentation(.showToast(toast)): diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 171b0188..ce88edb2 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -349,7 +349,7 @@ extension HomeReducer { guard date == state.calendarDate else { return .none } state.isLoading = false state.isFetchFailed = true - return .send(.presentation(.showToast(.warning(message: "목표 조회에 실패했어요")))) + return .none case let .internal(.setCalendarDate(date)): guard date != state.calendarDate else { return .none } diff --git a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift index ab75b3d9..8234c28f 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift @@ -135,7 +135,7 @@ extension StatsReducer { } state.isLoading = false state.isFetchFailed = true - return .send(.presentation(.showToast(.warning(message: "통계 조회에 실패했어요")))) + return .none case .delegate: return .none From 0631d3862decf97de95fb2cbbe7bd0d2cf359488 Mon Sep 17 00:00:00 2001 From: jihun Date: Mon, 15 Jun 2026 20:16:57 +0900 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=9A=94=EC=B2=AD=20=EC=83=81=ED=83=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20-=20#351?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Stats/StatsReducer.swift | 2 +- .../Sources/Stats/StatsReducer+Impl.swift | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift index 6da62a23..6e5e3951 100644 --- a/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift +++ b/Projects/Feature/Stats/Interface/Sources/Stats/StatsReducer.swift @@ -97,7 +97,7 @@ public struct StatsReducer { // MARK: - Response public enum Response: Equatable { - case fetchedStats(stats: Stats, month: String) + case fetchedStats(stats: Stats, month: String, isOngoing: Bool) case fetchStatsFailed(month: String, isOngoing: Bool) } diff --git a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift index 8234c28f..88430640 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsReducer+Impl.swift @@ -78,13 +78,22 @@ extension StatsReducer { 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(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 { @@ -111,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 From 8f258303f42e53583d259c746be61e29d038f1ab Mon Sep 17 00:00:00 2001 From: jihun Date: Mon, 15 Jun 2026 20:17:51 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix:=20DataRetryView=20=EC=95=88=EA=B2=B9?= =?UTF-8?q?=EC=B9=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-=20#351?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Detail/GoalDetailView.swift | 42 +++++++------ .../Home/Sources/Goal/EditGoalListView.swift | 28 ++++----- .../Feature/Home/Sources/Home/HomeView.swift | 15 +++-- .../Sources/NotificationView.swift | 22 ++++--- .../Sources/Account/AccountView.swift | 18 +++--- .../NotificationSettingsView.swift | 26 ++++---- .../Sources/Settings/SettingsView.swift | 26 ++++---- .../Sources/Detail/StatsDetailView.swift | 32 +++++----- .../Stats/Sources/Stats/StatsView.swift | 27 ++++---- .../Components/View/DataRetryView.swift | 63 ++++++++++++------- .../Sources/Modifiers/View+TxDataRetry.swift | 56 ----------------- 11 files changed, 165 insertions(+), 190 deletions(-) delete mode 100644 Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index d19c87e3..9c8af088 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -107,10 +107,6 @@ public struct GoalDetailView: View { myEmojiFlyingReactionOverlay } .txToast(item: $store.toast, customPadding: 54) - .txDataRetry( - isPresented: store.isFetchFailed, - onRetry: { store.send(.view(.dataRetryTapped)) } - ) .txLoading(isPresented: store.isSavingPhotoLog) } } @@ -122,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/Sources/Goal/EditGoalListView.swift b/Projects/Feature/Home/Sources/Goal/EditGoalListView.swift index 45720bf0..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)) @@ -60,10 +64,6 @@ struct EditGoalListView: View { .txToast(item: $store.toast, onButtonTap: { store.send(.view(.toastButtonTapped)) }) - .txDataRetry( - isPresented: store.isFetchFailed, - onRetry: { store.send(.view(.dataRetryTapped)) } - ) .txLoading(isPresented: store.isLoading) } } diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 3890bb46..4d11a77f 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -62,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) @@ -75,10 +78,6 @@ public struct HomeView: View { .modifier(PerfToastPresentationHarness(toast: $store.presentation.toast)) .modifier(PerfHomeCounterMarkersHarness()) .modifier(HomePresentationLayer(store: store)) - .txDataRetry( - isPresented: store.isFetchFailed, - onRetry: { store.send(.view(.dataRetryTapped)) } - ) .onAppear { store.send(.view(.onAppear)) } diff --git a/Projects/Feature/Notification/Sources/NotificationView.swift b/Projects/Feature/Notification/Sources/NotificationView.swift index 0d61827a..2c536162 100644 --- a/Projects/Feature/Notification/Sources/NotificationView.swift +++ b/Projects/Feature/Notification/Sources/NotificationView.swift @@ -23,14 +23,20 @@ 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) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } .ignoresSafeArea(.container, edges: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -39,10 +45,6 @@ public struct NotificationView: View { store.send(.view(.onAppear)) } .toolbar(.hidden, for: .navigationBar) - .txDataRetry( - isPresented: store.isFetchFailed, - onRetry: { store.send(.view(.dataRetryTapped)) } - ) .txLoading(isPresented: store.isLoading && store.notifications.isEmpty) } } diff --git a/Projects/Feature/Settings/Sources/Account/AccountView.swift b/Projects/Feature/Settings/Sources/Account/AccountView.swift index afbbb0db..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) @@ -32,10 +38,6 @@ struct AccountView: View { store.send(.view(.modalConfirmTapped)) } } - .txDataRetry( - isPresented: store.isSettingsFetchFailed, - onRetry: { store.send(.view(.settingsDataRetryTapped)) } - ) .txLoading(isPresented: store.isLoading) } } diff --git a/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift b/Projects/Feature/Settings/Sources/NotificationSettings/NotificationSettingsView.swift index 4f41163e..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) + } } } } @@ -42,10 +48,6 @@ struct NotificationSettingsView: View { store.send(.view(.notificationSettingsOnAppear)) } } - .txDataRetry( - isPresented: store.isNotificationSettingsFetchFailed, - onRetry: { store.send(.view(.notificationSettingsDataRetryTapped)) } - ) .txLoading(isPresented: store.isNotificationSettingsLoading) } } diff --git a/Projects/Feature/Settings/Sources/Settings/SettingsView.swift b/Projects/Feature/Settings/Sources/Settings/SettingsView.swift index f02e0122..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) @@ -62,10 +68,6 @@ public struct SettingsView: View { store.send(.view(.onAppear)) } .toolbar(.hidden, for: .navigationBar) - .txDataRetry( - isPresented: store.isSettingsFetchFailed, - onRetry: { store.send(.view(.settingsDataRetryTapped)) } - ) } private func dismissKeyboard() { diff --git a/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift b/Projects/Feature/Stats/Sources/Detail/StatsDetailView.swift index 9a7af834..1aaeffac 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) @@ -64,10 +70,6 @@ struct StatsDetailView: View { } } .txToast(item: $store.toast) - .txDataRetry( - isPresented: store.isFetchFailed, - onRetry: { store.send(.view(.dataRetryTapped)) } - ) .txLoading(isPresented: store.isLoading) } } diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index 003a698a..1ea9a743 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -19,25 +19,24 @@ 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) - .txDataRetry( - isPresented: store.isFetchFailed, - onRetry: { store.send(.view(.dataRetryTapped)) } - ) .toolbar(.hidden, for: .tabBar) } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift b/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift index 3607d9b8..c36955e7 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/View/DataRetryView.swift @@ -33,29 +33,50 @@ public struct DataRetryView: View { } public var body: some View { - 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 + 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 ) - .padding(.top, Spacing.spacing8) + .frame(width: proxy.size.width, height: proxy.size.height) + .background(Color.Common.white) } - .frame(width: Constants.frameWidth) + .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 + ) } } diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift deleted file mode 100644 index 3e9fa8d7..00000000 --- a/Projects/Shared/DesignSystem/Sources/Modifiers/View+TxDataRetry.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// View+TxDataRetry.swift -// SharedDesignSystem -// -// Created by 정지훈 on 6/8/26. -// - -import SwiftUI - -public extension View { - /// 데이터 로드 실패 상태에서 재시도 안내 View를 overlay로 표시합니다. - /// - /// ## 사용 예시 - /// ```swift - /// content - /// .txDataRetry( - /// isPresented: store.isFetchFailed, - /// onRetry: { store.send(.view(.dataRetryTapped)) } - /// ) - /// ``` - /// - /// - Parameters: - /// - isPresented: 재시도 안내 View 표시 여부입니다. - /// - onRetry: 재시도 버튼을 탭했을 때 실행할 동작입니다. - func txDataRetry( - isPresented: Bool, - onRetry: @escaping () -> Void - ) -> some View { - modifier( - TXDataRetryModifier( - isPresented: isPresented, - onRetry: onRetry - ) - ) - } -} - -private struct TXDataRetryModifier: ViewModifier { - let isPresented: Bool - let onRetry: () -> Void - - func body(content: Content) -> some View { - content - .overlay { - GeometryReader { proxy in - if isPresented { - DataRetryView(onTap: onRetry) - .position( - x: proxy.size.width / 2, - y: proxy.size.height / 2 - ) - } - } - } - } -}