From 16b9109daf11b3022526314b8193eb57d645232c Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Thu, 19 Mar 2026 18:33:33 +0900 Subject: [PATCH] refactor: separate action/states - #211 --- .../Interface/Sources/Home/HomeReducer.swift | 183 +++-- .../Sources/Root/HomeCoordinator.swift | 2 +- .../Home/Sources/Home/HomeReducer+Impl.swift | 693 ++++++++++-------- .../Feature/Home/Sources/Home/HomeView.swift | 86 +-- 4 files changed, 540 insertions(+), 424 deletions(-) diff --git a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift index d0caca61..1cd7cbbc 100644 --- a/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift +++ b/Projects/Feature/Home/Interface/Sources/Home/HomeReducer.swift @@ -28,6 +28,12 @@ import SharedUtil public struct HomeReducer { let reducer: Reduce private let proofPhotoReducer: ProofPhotoReducer + + /// 홈 화면에서 발생 가능한 에러 + public enum HomeError: Error, Equatable { + case unknown + case networkError + } @ObservableState /// 홈 화면에서 사용되는 상태 모델입니다. @@ -37,40 +43,67 @@ public struct HomeReducer { /// let state = HomeReducer.State() /// ``` public struct State: Equatable { - public var cards: [GoalCardItem] = [] - public var isLoading: Bool = true - public var mainTitle: String = "KEEPILUV" - public var calendarMonthTitle: String = "" - public var calendarWeeks: [[TXCalendarDateItem]] = [] - public var calendarDate: TXCalendarDate = .init() - public var calendarSheetDate: TXCalendarDate = .init() - public var goalsCache: [String: [GoalCardItem]] = [:] - public var isRefreshHidden: Bool = true - public var isCalendarSheetPresented: Bool = false - public var pendingDeleteGoalID: Int64? - public var pendingDeletePhotologID: Int64? - public var hasCards: Bool { !cards.isEmpty } - public let nowDate = CalendarNow() - public var toast: TXToastType? - public var modal: TXModalType? - public var isProofPhotoPresented: Bool = false - public var isAddGoalPresented: Bool = false - public var isCameraPermissionAlertPresented: Bool = false - public var hasUnreadNotification: Bool = false - + // MARK: - Nested Structs + + /// 도메인 데이터 (실제 데이터/캐시/선택값) + public struct Data: Equatable { + public var cards: [GoalCardItem] = [] + public var goalsCache: [String: [GoalCardItem]] = [:] + public var calendarDate: TXCalendarDate = .init() + public var calendarSheetDate: TXCalendarDate = .init() + public var calendarWeeks: [[TXCalendarDateItem]] = [] + public var pendingDeleteGoalID: Int64? + public var pendingDeletePhotologID: Int64? + + public init() {} + } + + /// UI 상태 (화면 관련 상태) + public struct UIState: Equatable { + public var isLoading: Bool = true + public var mainTitle: String = "KEEPILUV" + public var calendarMonthTitle: String = "" + public var isRefreshHidden: Bool = true + public var hasUnreadNotification: Bool = false + public let nowDate = CalendarNow() + + public init() {} + } + + /// 프레젠테이션 (toast, modal, sheet 등) + public struct Presentation: Equatable { + public var toast: TXToastType? + public var modal: TXModalType? + public var isCalendarSheetPresented: Bool = false + public var isProofPhotoPresented: Bool = false + public var isAddGoalPresented: Bool = false + public var isCameraPermissionAlertPresented: Bool = false + + public init() {} + } + + // MARK: - State Instances + + public var data = Data() + public var ui = UIState() + public var presentation = Presentation() + public var proofPhoto: ProofPhotoReducer.State? + + // MARK: - Computed Properties + + public var hasCards: Bool { !data.cards.isEmpty } + public var goalSectionTitle: String { let now = CalendarNow() let today = TXCalendarDate(year: now.year, month: now.month, day: now.day) - if calendarDate < today { + if data.calendarDate < today { return "지난 우리 목표" } - if today < calendarDate { + if today < data.calendarDate { return "다음 우리 목표" } return "오늘 우리 목표" } - - public var proofPhoto: ProofPhotoReducer.State? /// 기본 상태를 생성합니다. /// @@ -86,50 +119,50 @@ public struct HomeReducer { /// /// ## 사용 예시 /// ```swift - /// store.send(.onAppear) + /// store.send(.view(.onAppear)) /// ``` public enum Action: BindableAction { - case binding(BindingAction) - - // MARK: - Child Action - case proofPhoto(ProofPhotoReducer.Action) - - // MARK: - LifeCycle - case onAppear - - // MARK: - User Action - case calendarDateSelected(TXCalendarDateItem) - case weekCalendarSwipe(TXCalendar.SwipeGesture) - case navigationBarAction(TXNavigationBar.Action) - case monthCalendarConfirmTapped - case goalCheckButtonTapped(id: Int64, isChecked: Bool) - case modalConfirmTapped - case yourCardTapped(GoalCardItem) - case myCardTapped(GoalCardItem) - case headerTapped(GoalCardItem) - case floatingButtonTapped - case editButtonTapped - - // MARK: - Update State - case fetchGoals - case fetchGoalsCompleted([GoalCardItem], date: TXCalendarDate) - case setCalendarDate(TXCalendarDate) - case setCalendarSheetPresented(Bool) - case showToast(TXToastType) - case authorizationCompleted(id: Int64, isAuthorized: Bool) - case proofPhotoDismissed - case addGoalButtonTapped(GoalCategory) - case cameraPermissionAlertDismissed - case fetchGoalsFailed - case deletePhotoLogCompleted(goalId: Int64) - case deletePhotoLogFailed - case fetchUnreadResponse(Bool) - - // MARK: - Delegate - case delegate(Delegate) - - /// 홈 화면에서 외부로 전달하는 이벤트입니다. - public enum Delegate { + // MARK: - View (사용자 이벤트) + + public enum View: Equatable { + case onAppear + case calendarDateSelected(TXCalendarDateItem) + case weekCalendarSwipe(TXCalendar.SwipeGesture) + case navigationBarAction(TXNavigationBar.Action) + case monthCalendarConfirmTapped + case goalCheckButtonTapped(id: Int64, isChecked: Bool) + case modalConfirmTapped + case yourCardTapped(GoalCardItem) + case myCardTapped(GoalCardItem) + case headerTapped(GoalCardItem) + case floatingButtonTapped + case editButtonTapped + case addGoalButtonTapped(GoalCategory) + case cameraPermissionAlertDismissed + case proofPhotoDismissed + } + + // MARK: - Internal (Reducer 내부 Effect) + + public enum Internal: Equatable { + case fetchGoals + case setCalendarDate(TXCalendarDate) + case setCalendarSheetPresented(Bool) + case authorizationCompleted(id: Int64, isAuthorized: Bool) + } + + // MARK: - Response (비동기 응답) + + public enum Response: Equatable { + case fetchGoalsResult(Result<[GoalCardItem], HomeError>, date: TXCalendarDate) + case deletePhotoLogResult(Result) + case fetchUnreadResult(Bool) + case pokePartnerResult(Result) + } + + // MARK: - Delegate (부모에게 알림) + + public enum Delegate: Equatable { case goToGoalDetail(id: Int64, owner: GoalDetail.Owner, verificationDate: String) case goToStatsDetail(id: Int64) case goToMakeGoal(GoalCategory) @@ -137,6 +170,22 @@ public struct HomeReducer { case goToSettings case goToNotification } + + // MARK: - Presentation (프레젠테이션 관련) + + public enum Presentation: Equatable { + case showToast(TXToastType) + } + + // MARK: - Top-level cases + + case view(View) + case `internal`(Internal) + case response(Response) + case delegate(Delegate) + case presentation(Presentation) + case proofPhoto(ProofPhotoReducer.Action) + case binding(BindingAction) } /// 외부에서 주입한 Reduce로 HomeReducer를 구성합니다. diff --git a/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift b/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift index f489cee2..b402835a 100644 --- a/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift +++ b/Projects/Feature/Home/Interface/Sources/Root/HomeCoordinator.swift @@ -66,7 +66,7 @@ public struct HomeCoordinator { /// /// ## 사용 예시 /// ```swift - /// store.send(.home(.onAppear)) + /// store.send(.home(.view(.onAppear))) /// ``` public enum Action: BindableAction { case binding(BindingAction) diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 3c28625e..a397c3b1 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -63,15 +63,15 @@ private enum PokeCooldownManager { } } +// MARK: - HomeReducer Implementation + extension HomeReducer { /// 실제 로직을 포함한 HomeReducer를 생성합니다. - /// + /// /// ## 사용 예시 /// ```swift /// let reducer = HomeReducer() /// ``` - - // swiftlint:disable:next function_body_length public init( proofPhotoReducer: ProofPhotoReducer ) { @@ -79,342 +79,409 @@ extension HomeReducer { @Dependency(\.captureSessionClient) var captureSessionClient @Dependency(\.photoLogClient) var photoLogClient @Dependency(\.notificationClient) var notificationClient - - // swiftlint:disable:next closure_body_length + let reducer = Reduce { state, action in - switch action { - // MARK: - Life Cycle - case .onAppear: - if state.calendarDate.day == nil { - let now = state.nowDate - let date = TXCalendarDate( - year: now.year, - month: now.month, - day: now.day - ) - return .send(.setCalendarDate(date)) - } - state.isLoading = true - return .send(.fetchGoals) - - // MARK: - User Action - case let .calendarDateSelected(item): - guard let components = item.dateComponents, - let year = components.year, - let month = components.month, - let day = components.day else { - return .none - } - return .send(.setCalendarDate(TXCalendarDate(year: year, month: month, day: day))) - - case let .setCalendarSheetPresented(isPresented): - state.isCalendarSheetPresented = isPresented - if isPresented { - state.calendarSheetDate = state.calendarDate - } - return .none - - case let .navigationBarAction(action): - switch action { - case .refreshTapped: - let now = state.nowDate - let date = TXCalendarDate( - year: now.year, - month: now.month, - day: now.day - ) - if date == state.calendarDate { - state.isLoading = true - return .send(.fetchGoals) - } - return .send(.setCalendarDate(date)) - - case .subTitleTapped: - return .send(.setCalendarSheetPresented(true)) - - case .alertTapped: - return .send(.delegate(.goToNotification)) - - case .settingTapped: - return .send(.delegate(.goToSettings)) - - case .backTapped, - .rightTapped, - .closeTapped: - return .none - } - - case .monthCalendarConfirmTapped: - state.isCalendarSheetPresented = false - return .send(.setCalendarDate(state.calendarSheetDate)) - - case let .goalCheckButtonTapped(id, isChecked): - if isChecked { - guard let card = state.cards.first(where: { $0.id == id }), - let photologId = card.myCard.photologId else { - return .none - } - state.pendingDeleteGoalID = id - state.pendingDeletePhotologID = photologId - state.modal = .info(.uncheckGoal) - return .none - } else { - let now = state.nowDate - let today = TXCalendarDate( - year: now.year, - month: now.month, - day: now.day - ) - if state.calendarDate > today { - state.toast = .warning(message: "미래의 인증샷은 지금 올릴 수 없어요!") - return .none - } else { - return .run { send in - let isAuthorized = await captureSessionClient.fetchIsAuthorized() - await send(.authorizationCompleted(id: id, isAuthorized: isAuthorized)) - } - } - } - - case .modalConfirmTapped: - guard let pendingGoalID = state.pendingDeleteGoalID, - let pendingPhotologID = state.pendingDeletePhotologID else { - return .none - } - state.pendingDeleteGoalID = nil - state.pendingDeletePhotologID = nil - return .run { send in - do { - try await photoLogClient.deletePhotoLog(pendingPhotologID) - await send(.deletePhotoLogCompleted(goalId: pendingGoalID)) - } catch { - await send(.deletePhotoLogFailed) - } - } - - case let .yourCardTapped(card): - if !card.yourCard.isSelected { - // 쿨다운 확인 (3시간 이내 재요청 방지) - if let remaining = PokeCooldownManager.remainingCooldown(goalId: card.id) { - let timeText = PokeCooldownManager.formatRemainingTime(remaining) - return .send(.showToast(.warning(message: "\(timeText) 뒤에 다시 찌를 수 있어요"))) - } - // 상대방 미인증 시 찌르기 API 호출 - return .run { send in - do { - try await goalClient.pokePartner(card.id) - PokeCooldownManager.recordPoke(goalId: card.id) - await send(.showToast(.poke(message: "상대방을 찔렀어요!"))) - } catch { - await send(.showToast(.warning(message: "찌르기에 실패했어요"))) - } - } - } else { - let verificationDate = TXCalendarUtil.apiDateString(for: state.calendarDate) - return .send(.delegate(.goToGoalDetail(id: card.id, owner: .you, verificationDate: verificationDate))) - } - - case let .myCardTapped(card): - let verificationDate = TXCalendarUtil.apiDateString(for: state.calendarDate) - return .send(.delegate(.goToGoalDetail(id: card.id, owner: .mySelf, verificationDate: verificationDate))) - - case let .headerTapped(card): - return .send(.delegate(.goToStatsDetail(id: card.id))) - - case .floatingButtonTapped: - state.isAddGoalPresented = true - return .none - - case let .addGoalButtonTapped(category): - state.isAddGoalPresented = false - return .send(.delegate(.goToMakeGoal(category))) - - case .editButtonTapped: - return .send(.delegate(.goToEditGoalList(date: state.calendarDate))) - - case let .weekCalendarSwipe(swipe): - switch swipe { - case .next: - guard let nextWeekDate = TXCalendarUtil.dateByAddingWeek( - from: state.calendarDate, - by: 1 - ) else { - return .none - } - return .send(.setCalendarDate(nextWeekDate)) - - case .previous: - guard let previousWeekDate = TXCalendarUtil.dateByAddingWeek( - from: state.calendarDate, - by: -1 - ) else { - return .none - } - return .send(.setCalendarDate(previousWeekDate)) - } - - // MARK: - Update State - case let .fetchGoalsCompleted(items, date): - let cacheKey = TXCalendarUtil.apiDateString(for: date) - state.goalsCache[cacheKey] = items - - if date != state.calendarDate { - return .none - } - state.isLoading = false - if state.cards != items { - state.cards = items - } - return .none - - case .fetchGoalsFailed: - state.isLoading = false - return .send(.showToast(.warning(message: "목표 조회에 실패했어요"))) - - case let .setCalendarDate(date): - guard date != state.calendarDate else { return .none } - - let today = TXCalendarDate() - let calendar = Calendar(identifier: .gregorian) - - state.calendarDate = date - state.calendarMonthTitle = "\(date.month)월 \(date.year)" - state.calendarWeeks = TXCalendarDataGenerator.generateWeekData(for: date) - - if let selectedDate = date.date, - let todayDate = today.date { - let isThisWeek = calendar.isDate( - selectedDate, - equalTo: todayDate, - toGranularity: .weekOfYear - ) - state.isRefreshHidden = isThisWeek - } - - state.isLoading = true - return .send(.fetchGoals) - - case .fetchGoals: - let date = state.calendarDate - let cacheKey = TXCalendarUtil.apiDateString(for: date) - if let cachedItems = state.goalsCache[cacheKey] { - state.cards = cachedItems - state.isLoading = false - } else { - state.isLoading = true - } - return .run { send in - // 읽지 않은 알림 여부 체크 - if let hasUnread = try? await notificationClient.fetchUnread() { - await send(.fetchUnreadResponse(hasUnread)) - } - - do { - let goals = try await goalClient.fetchGoals(cacheKey) - let items: [GoalCardItem] = goals.map { goal in - let myImageURL = goal.myVerification?.imageURL.flatMap(URL.init(string:)) - let yourImageURL = goal.yourVerification?.imageURL.flatMap(URL.init(string:)) - return GoalCardItem( - id: goal.id, - goalName: goal.title, - goalEmoji: GoalIcon(from: goal.goalIcon).image, - myCard: .init( - photologId: goal.myVerification?.photologId, - imageURL: myImageURL, - isSelected: goal.myVerification?.isCompleted ?? false, - emoji: goal.myVerification?.emoji.flatMap { ReactionEmoji(from: $0)?.image } - ), - yourCard: .init( - photologId: goal.yourVerification?.photologId, - imageURL: yourImageURL, - isSelected: goal.yourVerification?.isCompleted ?? false, - emoji: goal.yourVerification?.emoji.flatMap { ReactionEmoji(from: $0)?.image } - ) - ) - } - await send(.fetchGoalsCompleted(items, date: date)) - } catch { - await send(.fetchGoalsFailed) - } - } - - case let .showToast(toast): - state.toast = toast - return .none - - case let .authorizationCompleted(id, isAuthorized): - if !isAuthorized { - state.isCameraPermissionAlertPresented = true - return .none - } - state.proofPhoto = .init( - goalId: id, - verificationDate: TXCalendarUtil.apiDateString(for: state.calendarDate) + case let .view(viewAction): + return reduceView( + state: &state, + action: viewAction, + goalClient: goalClient, + captureSessionClient: captureSessionClient, + photoLogClient: photoLogClient ) - state.isProofPhotoPresented = true - return .none - - case .cameraPermissionAlertDismissed: - state.isCameraPermissionAlertPresented = false + + case let .internal(internalAction): + return reduceInternal( + state: &state, + action: internalAction, + goalClient: goalClient, + notificationClient: notificationClient + ) + + case let .response(responseAction): + return reduceResponse( + state: &state, + action: responseAction + ) + + case let .presentation(presentationAction): + return reducePresentation( + state: &state, + action: presentationAction + ) + + case .delegate: return .none - + case .proofPhoto(.delegate(.closeProofPhoto)): - state.isProofPhotoPresented = false + state.presentation.isProofPhotoPresented = false return .none - + case let .proofPhoto(.delegate(.completedUploadPhoto(myPhotoLog, _))): - state.isProofPhotoPresented = false + state.presentation.isProofPhotoPresented = false guard let goalId = state.proofPhoto?.goalId else { return .none } - guard let index = state.cards.firstIndex(where: { $0.id == goalId }) else { return .none } + guard let index = state.data.cards.firstIndex(where: { $0.id == goalId }) else { return .none } let imageURL = myPhotoLog.imageUrl.flatMap(URL.init(string:)) - state.cards[index].myCard = .init( + state.data.cards[index].myCard = .init( photologId: myPhotoLog.photologId, imageURL: imageURL, isSelected: true, - emoji: state.cards[index].myCard.emoji + emoji: state.data.cards[index].myCard.emoji ) - state.goalsCache[TXCalendarUtil.apiDateString(for: state.calendarDate)] = state.cards - return .none - - case .proofPhotoDismissed: - state.proofPhoto = nil + state.data.goalsCache[TXCalendarUtil.apiDateString(for: state.data.calendarDate)] = state.data.cards return .none - + case .proofPhoto: return .none - case let .deletePhotoLogCompleted(goalId): - guard let index = state.cards.firstIndex(where: { $0.id == goalId }) else { - return .none - } - state.cards[index].myCard = .init( - photologId: nil, - imageURL: nil, - isSelected: false, - emoji: state.cards[index].myCard.emoji - ) - state.goalsCache[TXCalendarUtil.apiDateString(for: state.calendarDate)] = state.cards - return .send(.showToast(.delete(message: "인증이 해제되었어요"))) + case .binding: + return .none + } + } + + self.init( + reducer: reducer, + proofPhotoReducer: proofPhotoReducer + ) + } +} - case .deletePhotoLogFailed: - return .send(.showToast(.warning(message: "해제에 실패했어요"))) +// MARK: - View Actions - case let .fetchUnreadResponse(hasUnread): - state.hasUnreadNotification = hasUnread +private func reduceView( + state: inout HomeReducer.State, + action: HomeReducer.Action.View, + goalClient: GoalClient, + captureSessionClient: CaptureSessionClient, + photoLogClient: PhotoLogClient +) -> Effect { + switch action { + case .onAppear: + if state.data.calendarDate.day == nil { + let now = state.ui.nowDate + let date = TXCalendarDate( + year: now.year, + month: now.month, + day: now.day + ) + return .send(.internal(.setCalendarDate(date))) + } + state.ui.isLoading = true + return .send(.internal(.fetchGoals)) + + case let .calendarDateSelected(item): + guard let components = item.dateComponents, + let year = components.year, + let month = components.month, + let day = components.day else { + return .none + } + return .send(.internal(.setCalendarDate(TXCalendarDate(year: year, month: month, day: day)))) + + case let .weekCalendarSwipe(swipe): + switch swipe { + case .next: + guard let nextWeekDate = TXCalendarUtil.dateByAddingWeek( + from: state.data.calendarDate, + by: 1 + ) else { return .none + } + return .send(.internal(.setCalendarDate(nextWeekDate))) - case .binding: + case .previous: + guard let previousWeekDate = TXCalendarUtil.dateByAddingWeek( + from: state.data.calendarDate, + by: -1 + ) else { return .none + } + return .send(.internal(.setCalendarDate(previousWeekDate))) + } - case .delegate: + case let .navigationBarAction(action): + switch action { + case .refreshTapped: + let now = state.ui.nowDate + let date = TXCalendarDate( + year: now.year, + month: now.month, + day: now.day + ) + if date == state.data.calendarDate { + state.ui.isLoading = true + return .send(.internal(.fetchGoals)) + } + return .send(.internal(.setCalendarDate(date))) + + case .subTitleTapped: + return .send(.internal(.setCalendarSheetPresented(true))) + + case .alertTapped: + return .send(.delegate(.goToNotification)) + + case .settingTapped: + return .send(.delegate(.goToSettings)) + + case .backTapped, + .rightTapped, + .closeTapped: + return .none + } + + case .monthCalendarConfirmTapped: + state.presentation.isCalendarSheetPresented = false + return .send(.internal(.setCalendarDate(state.data.calendarSheetDate))) + + case let .goalCheckButtonTapped(id, isChecked): + if isChecked { + guard let card = state.data.cards.first(where: { $0.id == id }), + let photologId = card.myCard.photologId else { return .none } + state.data.pendingDeleteGoalID = id + state.data.pendingDeletePhotologID = photologId + state.presentation.modal = .info(.uncheckGoal) + return .none + } else { + let now = state.ui.nowDate + let today = TXCalendarDate( + year: now.year, + month: now.month, + day: now.day + ) + if state.data.calendarDate > today { + return .send(.presentation(.showToast(.warning(message: "미래의 인증샷은 지금 올릴 수 없어요!")))) + } else { + return .run { send in + let isAuthorized = await captureSessionClient.fetchIsAuthorized() + await send(.internal(.authorizationCompleted(id: id, isAuthorized: isAuthorized))) + } + } } - - self.init( - reducer: reducer, - proofPhotoReducer: proofPhotoReducer + + case .modalConfirmTapped: + guard let pendingGoalID = state.data.pendingDeleteGoalID, + let pendingPhotologID = state.data.pendingDeletePhotologID else { + return .none + } + state.data.pendingDeleteGoalID = nil + state.data.pendingDeletePhotologID = nil + return .run { send in + do { + try await photoLogClient.deletePhotoLog(pendingPhotologID) + await send(.response(.deletePhotoLogResult(.success(pendingGoalID)))) + } catch { + await send(.response(.deletePhotoLogResult(.failure(HomeReducer.HomeError.unknown)))) + } + } + + case let .yourCardTapped(card): + if !card.yourCard.isSelected { + if let remaining = PokeCooldownManager.remainingCooldown(goalId: card.id) { + let timeText = PokeCooldownManager.formatRemainingTime(remaining) + return .send(.presentation(.showToast(.warning(message: "\(timeText) 뒤에 다시 찌를 수 있어요")))) + } + return .run { send in + do { + try await goalClient.pokePartner(card.id) + PokeCooldownManager.recordPoke(goalId: card.id) + await send(.response(.pokePartnerResult(.success(card.id)))) + } catch { + await send(.response(.pokePartnerResult(.failure(HomeReducer.HomeError.unknown)))) + } + } + } else { + let verificationDate = TXCalendarUtil.apiDateString(for: state.data.calendarDate) + return .send(.delegate(.goToGoalDetail(id: card.id, owner: .you, verificationDate: verificationDate))) + } + + case let .myCardTapped(card): + let verificationDate = TXCalendarUtil.apiDateString(for: state.data.calendarDate) + return .send(.delegate(.goToGoalDetail(id: card.id, owner: .mySelf, verificationDate: verificationDate))) + + case let .headerTapped(card): + return .send(.delegate(.goToStatsDetail(id: card.id))) + + case .floatingButtonTapped: + state.presentation.isAddGoalPresented = true + return .none + + case .editButtonTapped: + return .send(.delegate(.goToEditGoalList(date: state.data.calendarDate))) + + case let .addGoalButtonTapped(category): + state.presentation.isAddGoalPresented = false + return .send(.delegate(.goToMakeGoal(category))) + + case .cameraPermissionAlertDismissed: + state.presentation.isCameraPermissionAlertPresented = false + return .none + + case .proofPhotoDismissed: + state.proofPhoto = nil + return .none + } +} + +// MARK: - Internal Actions + +private func reduceInternal( + state: inout HomeReducer.State, + action: HomeReducer.Action.Internal, + goalClient: GoalClient, + notificationClient: NotificationClient +) -> Effect { + switch action { + case .fetchGoals: + let date = state.data.calendarDate + let cacheKey = TXCalendarUtil.apiDateString(for: date) + if let cachedItems = state.data.goalsCache[cacheKey] { + state.data.cards = cachedItems + state.ui.isLoading = false + } else { + state.ui.isLoading = true + } + return .run { send in + if let hasUnread = try? await notificationClient.fetchUnread() { + await send(.response(.fetchUnreadResult(hasUnread))) + } + + do { + let goals = try await goalClient.fetchGoals(cacheKey) + let items: [GoalCardItem] = goals.map { goal in + let myImageURL = goal.myVerification?.imageURL.flatMap(URL.init(string:)) + let yourImageURL = goal.yourVerification?.imageURL.flatMap(URL.init(string:)) + return GoalCardItem( + id: goal.id, + goalName: goal.title, + goalEmoji: GoalIcon(from: goal.goalIcon).image, + myCard: .init( + photologId: goal.myVerification?.photologId, + imageURL: myImageURL, + isSelected: goal.myVerification?.isCompleted ?? false, + emoji: goal.myVerification?.emoji.flatMap { ReactionEmoji(from: $0)?.image } + ), + yourCard: .init( + photologId: goal.yourVerification?.photologId, + imageURL: yourImageURL, + isSelected: goal.yourVerification?.isCompleted ?? false, + emoji: goal.yourVerification?.emoji.flatMap { ReactionEmoji(from: $0)?.image } + ) + ) + } + await send(.response(.fetchGoalsResult(.success(items), date: date))) + } catch { + await send(.response(.fetchGoalsResult(.failure(HomeReducer.HomeError.unknown), date: date))) + } + } + + case let .setCalendarDate(date): + guard date != state.data.calendarDate else { return .none } + + let today = TXCalendarDate() + let calendar = Calendar(identifier: .gregorian) + + state.data.calendarDate = date + state.ui.calendarMonthTitle = "\(date.month)월 \(date.year)" + state.data.calendarWeeks = TXCalendarDataGenerator.generateWeekData(for: date) + + if let selectedDate = date.date, + let todayDate = today.date { + let isThisWeek = calendar.isDate( + selectedDate, + equalTo: todayDate, + toGranularity: .weekOfYear + ) + state.ui.isRefreshHidden = isThisWeek + } + + state.ui.isLoading = true + return .send(.internal(.fetchGoals)) + + case let .setCalendarSheetPresented(isPresented): + state.presentation.isCalendarSheetPresented = isPresented + if isPresented { + state.data.calendarSheetDate = state.data.calendarDate + } + return .none + + case let .authorizationCompleted(id, isAuthorized): + if !isAuthorized { + state.presentation.isCameraPermissionAlertPresented = true + return .none + } + state.proofPhoto = .init( + goalId: id, + verificationDate: TXCalendarUtil.apiDateString(for: state.data.calendarDate) ) + state.presentation.isProofPhotoPresented = true + return .none + } +} + +// MARK: - Response Actions + +private func reduceResponse( + state: inout HomeReducer.State, + action: HomeReducer.Action.Response +) -> Effect { + switch action { + case let .fetchGoalsResult(.success(items), date): + let cacheKey = TXCalendarUtil.apiDateString(for: date) + state.data.goalsCache[cacheKey] = items + + if date != state.data.calendarDate { + return .none + } + state.ui.isLoading = false + if state.data.cards != items { + state.data.cards = items + } + return .none + + case .fetchGoalsResult(.failure, _): + state.ui.isLoading = false + return .send(.presentation(.showToast(.warning(message: "목표 조회에 실패했어요")))) + + case let .deletePhotoLogResult(.success(goalId)): + guard let index = state.data.cards.firstIndex(where: { $0.id == goalId }) else { + return .none + } + state.data.cards[index].myCard = .init( + photologId: nil, + imageURL: nil, + isSelected: false, + emoji: state.data.cards[index].myCard.emoji + ) + state.data.goalsCache[TXCalendarUtil.apiDateString(for: state.data.calendarDate)] = state.data.cards + return .send(.presentation(.showToast(.delete(message: "인증이 해제되었어요")))) + + case .deletePhotoLogResult(.failure): + return .send(.presentation(.showToast(.warning(message: "해제에 실패했어요")))) + + case let .fetchUnreadResult(hasUnread): + state.ui.hasUnreadNotification = hasUnread + return .none + + case .pokePartnerResult(.success): + return .send(.presentation(.showToast(.poke(message: "상대방을 찔렀어요!")))) + + case .pokePartnerResult(.failure): + return .send(.presentation(.showToast(.warning(message: "찌르기에 실패했어요")))) + } +} + +// MARK: - Presentation Actions + +private func reducePresentation( + state: inout HomeReducer.State, + action: HomeReducer.Action.Presentation +) -> Effect { + switch action { + case let .showToast(toast): + state.presentation.toast = toast + return .none } } diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 3e841de3..8a853777 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -25,10 +25,10 @@ import SharedDesignSystem /// ) /// ``` public struct HomeView: View { - + @Bindable public var store: StoreOf @Dependency(\.proofPhotoFactory) var proofPhotoFactory - + /// HomeView를 생성합니다. /// /// ## 사용 예시 @@ -38,7 +38,7 @@ public struct HomeView: View { public init(store: StoreOf) { self.store = store } - + public var body: some View { VStack(spacing: 0) { navigationBar @@ -53,11 +53,11 @@ public struct HomeView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .overlay { - if store.isLoading { + if store.ui.isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } - + if !store.hasCards { goalEmptyView } @@ -68,34 +68,34 @@ public struct HomeView: View { } } .onAppear { - store.send(.onAppear) + store.send(.view(.onAppear)) } .txBottomSheet( - isPresented: $store.isAddGoalPresented, + isPresented: $store.presentation.isAddGoalPresented, showDragIndicator: true, sheetContent: { AddGoalListView { category in - store.send(.addGoalButtonTapped(category)) + store.send(.view(.addGoalButtonTapped(category))) } } ) .txBottomSheet( - isPresented: $store.isCalendarSheetPresented, + isPresented: $store.presentation.isCalendarSheetPresented, sheetContent: { TXCalendarBottomSheet( - selectedDate: $store.calendarSheetDate, + selectedDate: $store.data.calendarSheetDate, completeButtonText: "완료", onComplete: { - store.send(.monthCalendarConfirmTapped) + store.send(.view(.monthCalendarConfirmTapped)) } ) } ) .txModal( - item: $store.modal, + item: $store.presentation.modal, onAction: { action in if action == .confirm { - store.send(.modalConfirmTapped) + store.send(.view(.modalConfirmTapped)) } } ) @@ -103,16 +103,16 @@ public struct HomeView: View { transaction.disablesAnimations = false } .fullScreenCover( - isPresented: $store.isProofPhotoPresented, - onDismiss: { store.send(.proofPhotoDismissed) }, + isPresented: $store.presentation.isProofPhotoPresented, + onDismiss: { store.send(.view(.proofPhotoDismissed)) }, ) { IfLetStore(store.scope(state: \.proofPhoto, action: \.proofPhoto)) { store in proofPhotoFactory.makeView(store) } } .cameraPermissionAlert( - isPresented: $store.isCameraPermissionAlertPresented, - onDismiss: { store.send(.cameraPermissionAlertDismissed) } + isPresented: $store.presentation.isCameraPermissionAlertPresented, + onDismiss: { store.send(.view(.cameraPermissionAlertDismissed)) } ) .toolbar(.hidden, for: .navigationBar) } @@ -124,34 +124,34 @@ private extension HomeView { TXNavigationBar( style: .home( .init( - subTitle: store.calendarMonthTitle, - mainTitle: store.mainTitle, - isHiddenRefresh: store.isRefreshHidden, - isRemainedAlarm: store.hasUnreadNotification + subTitle: store.ui.calendarMonthTitle, + mainTitle: store.ui.mainTitle, + isHiddenRefresh: store.ui.isRefreshHidden, + isRemainedAlarm: store.ui.hasUnreadNotification ) ), onAction: { action in - store.send(.navigationBarAction(action)) + store.send(.view(.navigationBarAction(action))) } ) } - + var calendar: some View { TXCalendar( mode: .weekly, - weeks: store.calendarWeeks, + weeks: store.data.calendarWeeks, config: .init( dateStyle: .init(lastDateTextColor: Color.Gray.gray500) ), onSelect: { item in - store.send(.calendarDateSelected(item)) + store.send(.view(.calendarDateSelected(item))) }, onSwipe: { swipe in - store.send(.weekCalendarSwipe(swipe)) + store.send(.view(.weekCalendarSwipe(swipe))) } ) .frame(maxWidth: .infinity, maxHeight: 76) } - + var content: some View { ScrollView { Group { @@ -163,19 +163,19 @@ private extension HomeView { .padding(.bottom, 103) } .refreshable { - store.send(.fetchGoals) + store.send(.internal(.fetchGoals)) } } - + var headerRow: some View { HStack(spacing: 0) { Text(store.goalSectionTitle) .typography(.b1_14b) - + Spacer() - + Button { - store.send(.editButtonTapped) + store.send(.view(.editButtonTapped)) } label: { Text("편집") .typography(.b1_14b) @@ -184,16 +184,16 @@ private extension HomeView { } .frame(height: 24) } - + var cardList: some View { LazyVStack(spacing: 16) { - ForEach(store.cards) { card in + ForEach(store.data.cards) { card in goalCard(for: card) } } .padding(.top, 12) } - + func goalCard(for card: GoalCardItem) -> some View { GoalCardView( config: .goalCheck( @@ -207,30 +207,30 @@ private extension HomeView { isMyChecked: card.myCard.isSelected, isCoupleChecked: card.yourCard.isSelected, action: { - store.send(.goalCheckButtonTapped(id: card.id, isChecked: card.myCard.isSelected)) + store.send(.view(.goalCheckButtonTapped(id: card.id, isChecked: card.myCard.isSelected))) }, onHeaderTapped: { - store.send(.headerTapped(card)) + store.send(.view(.headerTapped(card))) } ), actionLeft: { - store.send(.myCardTapped(card)) + store.send(.view(.myCardTapped(card))) }, actionRight: { - store.send(.yourCardTapped(card)) + store.send(.view(.yourCardTapped(card))) } ) } - + var goalEmptyView: some View { VStack(spacing: 0) { Image.Illustration.emptyPoke .frame(height: 116) - + Text("첫 목표를 세워볼까요?") .typography(.t2_16b) .foregroundStyle(Color.Gray.gray400) .padding(.top, 16) - + Text("+ 버튼을 눌러 목표를 추가해보세요") .typography(.c1_12r) .foregroundStyle(Color.Gray.gray300) @@ -239,7 +239,7 @@ private extension HomeView { .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() } - + var emptyArrow: some View { Image.Illustration.arrow .padding(.bottom, 71)