diff --git a/TnT/Projects/Data/Sources/Network/NetworkService.swift b/TnT/Projects/Data/Sources/Network/NetworkService.swift index 3d7e096d..9b1e4d22 100644 --- a/TnT/Projects/Data/Sources/Network/NetworkService.swift +++ b/TnT/Projects/Data/Sources/Network/NetworkService.swift @@ -42,10 +42,15 @@ public final class NetworkService { // Data 디코딩 return try decodeData(data, as: decodingType) - } catch { + } catch let error as NetworkError { // TODO: 추후 인터셉터 리팩토링 시 error middleWare로 분리 NotificationCenter.default.postProgress(visible: false) - NotificationCenter.default.post(toast: .init(presentType: .text("⚠"), message: "서버 요청에 실패했어요")) + switch error { + case .unauthorized: + NotificationCenter.default.postSessionExpired() + default: + NotificationCenter.default.post(toast: .init(presentType: .text("⚠"), message: "서버 요청에 실패했어요")) + } throw error } } diff --git a/TnT/Projects/Domain/Sources/Extension/Notification+.swift b/TnT/Projects/Domain/Sources/Extension/Notification+.swift index e60411d9..f1bf7ecf 100644 --- a/TnT/Projects/Domain/Sources/Extension/Notification+.swift +++ b/TnT/Projects/Domain/Sources/Extension/Notification+.swift @@ -17,6 +17,8 @@ public extension Notification.Name { static let showProgressNotification = Notification.Name("ShowProgressNotification") /// ProgressView를 숨기기 위한 노티피케이션 이름 static let hideProgressNotification = Notification.Name("HideProgressNotification") + /// 세션 만료 팝업을 표시하기 위한 노티피케이션 이름 + static let showSessionExpiredPopupNotification = Notification.Name("ShowSessionExpiredPopupNotification") } public extension NotificationCenter { @@ -43,4 +45,9 @@ public extension NotificationCenter { let name: Notification.Name = visible ? .showProgressNotification : .hideProgressNotification self.post(name: name, object: nil) } + + /// 세션 만료 팝업 표시를 요청하는 편의 메서드 + func postSessionExpired() { + NotificationCenter.default.post(name: .showSessionExpiredPopupNotification, object: nil) + } } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift index a49ae8e2..81b9930e 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift @@ -26,6 +26,7 @@ public struct AppFlowCoordinatorFeature { var userType: UserType? // MARK: UI related state var view_isSplashActive: Bool + var view_isPopUpPresented: Bool // MARK: SubFeature state var trainerMainState: TrainerMainFlowFeature.State? @@ -34,20 +35,23 @@ public struct AppFlowCoordinatorFeature { public init( userType: UserType? = nil, - view_isSplashActive: Bool = true,y + view_isSplashActive: Bool = true, + view_isPopUpPresented: Bool = false, onboardingState: OnboardingFlowFeature.State? = nil, trainerMainState: TrainerMainFlowFeature.State? = nil, traineeMainState: TraineeMainFlowFeature.State? = nil ) { self.userType = userType self.view_isSplashActive = view_isSplashActive + self.view_isPopUpPresented = view_isPopUpPresented self.onboardingState = onboardingState self.trainerMainState = trainerMainState self.traineeMainState = traineeMainState } } - public enum Action { + public enum Action: ViewAction { + case view(View) /// 하위 코디네이터에서 일어나는 액션을 처리합니다 case subFeature(SubFeatureAction) /// api 콜 액션을 처리합니다 @@ -58,8 +62,18 @@ public struct AppFlowCoordinatorFeature { case updateUserInfo(UserType?) /// 스플래시 표시 종료 시 case splashFinished - /// 첫 진입 시 - case onAppear + /// 세션 만료 팝업 표시 + case showSessionExpiredPopup + + @CasePathable + public enum View: Sendable, BindableAction { + /// 바인딩할 액션을 처리 + case binding(BindingAction) + /// 첫 진입 시 + case onAppear + /// 세션 만료 팝업 확인 버튼 탭 + case tapSessionExpiredPopupConfirmButton + } @CasePathable public enum SubFeatureAction: Sendable { @@ -84,8 +98,33 @@ public struct AppFlowCoordinatorFeature { public init() {} public var body: some Reducer { + BindingReducer(action: \.view) + Reduce { state, action in switch action { + case .view(let action): + switch action { + case .binding: + return .none + + case .onAppear: + return .merge( + .run { send in + try await Task.sleep(for: .seconds(1)) + await send(.splashFinished) + }, + .send(.checkSessionInfo), + .run { send in + for await _ in NotificationCenter.default.notifications(named: .showSessionExpiredPopupNotification) { + await send(.showSessionExpiredPopup) + } + } + ) + case .tapSessionExpiredPopupConfirmButton: + state.view_isPopUpPresented = false + return self.setFlow(.onboardingFlow, state: &state) + } + case .subFeature(let internalAction): switch internalAction { case .onboardingFlow(.switchFlow(let flow)), @@ -95,7 +134,7 @@ public struct AppFlowCoordinatorFeature { default: return .none } - + case .api(let action): switch action { case .checkSession: @@ -112,7 +151,7 @@ public struct AppFlowCoordinatorFeature { } } } - + case .checkSessionInfo: let session: String? = try? keyChainManager.read(for: .sessionId) return session != nil @@ -133,14 +172,9 @@ public struct AppFlowCoordinatorFeature { state.view_isSplashActive = false return .none - case .onAppear: - return .merge( - .run { send in - try await Task.sleep(for: .seconds(1)) - await send(.splashFinished) - }, - .send(.checkSessionInfo) - ) + case .showSessionExpiredPopup: + state.view_isPopUpPresented = true + return .none } } .ifLet(\.onboardingState, action: \.subFeature.onboardingFlow) { OnboardingFlowFeature() } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift index feedbdf3..c21df05a 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift @@ -9,8 +9,11 @@ import SwiftUI import ComposableArchitecture +import DesignSystem + +@ViewAction(for: AppFlowCoordinatorFeature.self) public struct AppFlowCoordinatorView: View { - let store: StoreOf + @Bindable public var store: StoreOf public init(store: StoreOf) { self.store = store @@ -47,8 +50,24 @@ public struct AppFlowCoordinatorView: View { } } .animation(.easeInOut, value: store.view_isSplashActive) - .onAppear { - store.send(.onAppear) + .onAppear { send(.onAppear) } + .tPopUp(isPresented: $store.view_isPopUpPresented) { + .init( + alertState: .init( + title: "세션이 만료되었어요", + message: "장시간 미사용으로 로그인 화면으로 이동해요", + showAlertIcon: true, + buttons: [ + TPopupAlertState.ButtonState( + title: "확인", + style: .secondary, + action: .init(action: { + send(.tapSessionExpiredPopupConfirmButton) + }) + ) + ] + ) + ) } } }