diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift index e6729693..54d9c4b6 100644 --- a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift +++ b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift @@ -50,7 +50,7 @@ public struct TrainerRepositoryImpl: TrainerRepository { return try await networkService.request(TrainerTargetType.getMonthlyLessonList(year: year, month: month), decodingType: GetMonthlyLessonListResDTO.self) } - public func getConnectedTraineeInfo(trainerId: Int, traineeId: Int) async throws -> GetConnectedTraineeInfoResponseDTO { + public func getConnectedTraineeInfo(trainerId: Int64, traineeId: Int64) async throws -> GetConnectedTraineeInfoResponseDTO { return try await networkService.request(TrainerTargetType.getConnectedTraineeInfo(trainerId: trainerId, traineeId: traineeId), decodingType: GetConnectedTraineeInfoResponseDTO.self) } diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift index e78aa455..9fc3da79 100644 --- a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift +++ b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift @@ -25,7 +25,7 @@ public enum TrainerTargetType { /// 회원 조희 case getMemebersList /// 연결 완료된 트레이니 최초로 정보 불러오기 - case getConnectedTraineeInfo(trainerId: Int, traineeId: Int) + case getConnectedTraineeInfo(trainerId: Int64, traineeId: Int64) /// 관리 중인 회원 목록 요청 case getActiveTraineesList /// PT 수업 추가 diff --git a/TnT/Projects/Domain/Sources/DTO/Trainer/GetConnectedTraineeInfoResponseDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainer/GetConnectedTraineeInfoResponseDTO.swift index 96ca2dd7..77888f53 100644 --- a/TnT/Projects/Domain/Sources/DTO/Trainer/GetConnectedTraineeInfoResponseDTO.swift +++ b/TnT/Projects/Domain/Sources/DTO/Trainer/GetConnectedTraineeInfoResponseDTO.swift @@ -31,11 +31,11 @@ public struct ConnectTraineeInfoDTO: Decodable { /// 나이 let age: Int? /// 키 - let height: Double + let height: Double? /// 몸무게 - let weight: Double + let weight: Double? /// PT 목표 - let ptGoal: String + let ptGoal: String? /// 주의 사항 let cautionNote: String? } diff --git a/TnT/Projects/Domain/Sources/Extension/Notification+.swift b/TnT/Projects/Domain/Sources/Extension/Notification+.swift index f1bf7ecf..6b33c513 100644 --- a/TnT/Projects/Domain/Sources/Extension/Notification+.swift +++ b/TnT/Projects/Domain/Sources/Extension/Notification+.swift @@ -19,6 +19,8 @@ public extension Notification.Name { static let hideProgressNotification = Notification.Name("HideProgressNotification") /// 세션 만료 팝업을 표시하기 위한 노티피케이션 이름 static let showSessionExpiredPopupNotification = Notification.Name("ShowSessionExpiredPopupNotification") + /// FCM 토큰 - 트레이너/트레이니 연결 완료 receive 노티피케이션 이름 + static let fcmUserConnectedNotification = Notification.Name("FCMUserConnectedNotification") } public extension NotificationCenter { diff --git a/TnT/Projects/Domain/Sources/Mapper/TrainerMapper.swift b/TnT/Projects/Domain/Sources/Mapper/TrainerMapper.swift index 4db2f76c..2fdbbaa3 100644 --- a/TnT/Projects/Domain/Sources/Mapper/TrainerMapper.swift +++ b/TnT/Projects/Domain/Sources/Mapper/TrainerMapper.swift @@ -18,7 +18,7 @@ public extension ConnectTraineeInfoDTO { age: self.age, height: height, weight: weight, - ptGoal: self.ptGoal, + ptGoal: self.ptGoal ?? "", cautionNote: self.cautionNote ) } diff --git a/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift index 7e86967d..2dfb8fac 100644 --- a/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift +++ b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift @@ -30,7 +30,7 @@ public protocol TrainerRepository { func getMonthlyLessonList(year: Int, month: Int) async throws -> GetMonthlyLessonListResDTO /// 연결 완료된 트레이니 정보 불러오기 - func getConnectedTraineeInfo(trainerId: Int, traineeId: Int) async throws -> GetConnectedTraineeInfoResponseDTO + func getConnectedTraineeInfo(trainerId: Int64, traineeId: Int64) async throws -> GetConnectedTraineeInfoResponseDTO /// 관리 중인 회원 목록 요청 func getActiveTraineesList() async throws -> GetActiveTraineesListResDTO diff --git a/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift b/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift index 7401b58e..918c127b 100644 --- a/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift +++ b/TnT/Projects/Domain/Sources/UseCase/TrainerUseCase.swift @@ -33,7 +33,7 @@ public struct DefaultTrainerUseCase: TrainerRepository { return try await trainerRepository.getDateSessionList(date: date) } - public func getConnectedTraineeInfo(trainerId: Int, traineeId: Int) async throws -> GetConnectedTraineeInfoResponseDTO { + public func getConnectedTraineeInfo(trainerId: Int64, traineeId: Int64) async throws -> GetConnectedTraineeInfoResponseDTO { return try await trainerRepository.getConnectedTraineeInfo(trainerId: trainerId, traineeId: traineeId) } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift index 707c5313..1aca9075 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift @@ -13,9 +13,9 @@ import Domain import DIContainer public enum AppFlow: Sendable { - case onboardingFlow - case traineeMainFlow - case trainerMainFlow + case onboardingFlow(OnboardingFlowFeature.State) + case traineeMainFlow(TraineeMainFlowFeature.State) + case trainerMainFlow(TrainerMainFlowFeature.State) } @Reducer @@ -67,8 +67,6 @@ public struct AppFlowCoordinatorFeature { case updateUserInfo(type: UserType?, isConnected: Bool) /// 스플래시 표시 종료 시 case splashFinished - /// 세션 만료 팝업 표시 - case showSessionExpiredPopup @CasePathable public enum View: Sendable, BindableAction { @@ -78,6 +76,8 @@ public struct AppFlowCoordinatorFeature { case onAppear /// 세션 만료 팝업 확인 버튼 탭 case tapSessionExpiredPopupConfirmButton + /// Notification 액션 처리 + case notification(NotificationAction) } @CasePathable @@ -95,6 +95,14 @@ public struct AppFlowCoordinatorFeature { /// 로그인 세션 유효 확인 API case checkSession } + + @CasePathable + public enum NotificationAction: Sendable { + /// 세션 만료 팝업 표시 + case showSessionExpiredPopup + /// 트레이너/트레이니 연결 완료 알림 탭 시 + case showConnectCompletion(trainerId: Int64, traineeId: Int64) + } } @Dependency(\.userUseRepoCase) private var userUseCaseRepo: UserRepository @@ -118,16 +126,27 @@ public struct AppFlowCoordinatorFeature { 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) - } - } + .send(.checkSessionInfo) ) + case .tapSessionExpiredPopupConfirmButton: state.view_isPopUpPresented = false - return self.setFlow(.onboardingFlow, state: &state) + return self.setFlow(.onboardingFlow(.init()), state: &state) + + case .notification(let action): + switch action { + case .showSessionExpiredPopup: + state.view_isPopUpPresented = true + return .none + + case let .showConnectCompletion(trainerId, traineeId): + return self.setFlow(.trainerMainFlow(.init(path: + .init([ + .mainTab(.home(.init())), + .connectionComplete(.init(traineeId: traineeId, trainerId: trainerId)) + ]) + )), state: &state) + } } case .subFeature(let internalAction): @@ -173,20 +192,16 @@ public struct AppFlowCoordinatorFeature { switch userType { case .trainee: - return self.setFlow(.traineeMainFlow, state: &state) + return self.setFlow(.traineeMainFlow(.init()), state: &state) case .trainer: - return self.setFlow(.trainerMainFlow, state: &state) + return self.setFlow(.trainerMainFlow(.init()), state: &state) default: - return self.setFlow(.onboardingFlow, state: &state) + return self.setFlow(.onboardingFlow(.init()), state: &state) } case .splashFinished: state.view_isSplashActive = false return .none - - case .showSessionExpiredPopup: - state.view_isPopUpPresented = true - return .none } } .ifLet(\.onboardingState, action: \.subFeature.onboardingFlow) { OnboardingFlowFeature() } @@ -204,15 +219,15 @@ extension AppFlowCoordinatorFeature { state.trainerMainState = nil switch flow { - case .onboardingFlow: + case .onboardingFlow(let flowState): state.userType = nil - state.onboardingState = .init() - case .traineeMainFlow: + state.onboardingState = flowState + case .traineeMainFlow(let flowState): state.userType = .trainee - state.traineeMainState = .init() - case .trainerMainFlow: + state.traineeMainState = flowState + case .trainerMainFlow(let flowState): state.userType = .trainer - state.trainerMainState = .init() + state.trainerMainState = flowState } return .none diff --git a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift index c21df05a..9bca0783 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorView.swift @@ -69,5 +69,30 @@ public struct AppFlowCoordinatorView: View { ) ) } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name.showSessionExpiredPopupNotification)) { notification in + send(.notification(.showSessionExpiredPopup)) + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name.fcmUserConnectedNotification)) { notification in + guard let userInfo = notification.userInfo else { return } + let trainerId: Int64 = { + if let value = userInfo["trainerId"] as? Int64 { + return value + } else if let string = userInfo["trainerId"] as? String, + let value = Int64(string) { + return value + } + return 0 + }() + let traineeId: Int64 = { + if let value = userInfo["traineeId"] as? Int64 { + return value + } else if let string = userInfo["traineeId"] as? String, + let value = Int64(string) { + return value + } + return 0 + }() + send(.notification(.showConnectCompletion(trainerId: trainerId, traineeId: traineeId))) + } } } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/OnboardingFlow/OnboardingFlowFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/OnboardingFlow/OnboardingFlowFeature.swift index b3ebc3f3..ed70ea5f 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/OnboardingFlow/OnboardingFlowFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/OnboardingFlow/OnboardingFlowFeature.swift @@ -14,7 +14,7 @@ import Domain @Reducer public struct OnboardingFlowFeature { @ObservableState - public struct State: Equatable { + public struct State: Equatable, Sendable { public var path: StackState @Shared var signUpEntity: PostSignUpEntity @@ -47,9 +47,9 @@ public struct OnboardingFlowFeature { case .element(id: _, action: .snsLogin(.setNavigating(let screen))): switch screen { case .traineeHome: - return .send(.switchFlow(.traineeMainFlow)) + return .send(.switchFlow(.traineeMainFlow(.init()))) case .trainerHome: - return .send(.switchFlow(.trainerMainFlow)) + return .send(.switchFlow(.trainerMainFlow(.init()))) case .userTypeSelection: state.path.append(.userTypeSelection(.init(signUpEntity: state.$signUpEntity))) } @@ -83,7 +83,7 @@ public struct OnboardingFlowFeature { /// 트레이너 초대 코드 생성 화면 -> 트레이너 홈 이동 case .element(id: _, action: .trainerMakeInvitationCode(.setNavigation)): - return .send(.switchFlow(.trainerMainFlow)) + return .send(.switchFlow(.trainerMainFlow(.init()))) // MARK: Trainee /// 트레이니 기본 정보 입력 -> PT 목적 설정 화면 이동 @@ -110,7 +110,7 @@ public struct OnboardingFlowFeature { case .element(id: _, action: .traineeInvitationCodeInput(.setNavigating(let screen))): switch screen { case .traineeHome: - return .send(.switchFlow(.traineeMainFlow)) + return .send(.switchFlow(.traineeMainFlow(.init()))) case let .trainingInfoInput(trainerName, invitationCode): state.path.append(.traineeTrainingInfoInput(.init(trainerName: trainerName, invitationCode: invitationCode))) return .none @@ -133,7 +133,7 @@ public struct OnboardingFlowFeature { /// 트레이니 트레이너 연결완료 -> 트레이니 홈화면 case .element(id: _, action: .traineeConnectionComplete(.setNavigating)): - return .send(.switchFlow(.traineeMainFlow)) + return .send(.switchFlow(.traineeMainFlow(.init()))) default: return .none diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift index 74a4e5e7..ed340219 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift @@ -78,7 +78,7 @@ public struct TraineeMainFlowFeature { /// 마이페이지 로그아웃/회원탈퇴 -> 온보딩 로그인 화면 이동 case .onboardingLogin: - return .send(.switchFlow(.onboardingFlow)) + return .send(.switchFlow(.onboardingFlow(.init()))) } } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift index 0f39c02f..ca1c531f 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift @@ -14,7 +14,7 @@ import Domain @Reducer public struct TrainerMainFlowFeature { @ObservableState - public struct State: Equatable { + public struct State: Equatable, Sendable { public var path: StackState public init(path: StackState = .init([.mainTab(.home(.init()))])) { @@ -69,7 +69,7 @@ public struct TrainerMainFlowFeature { case .trainerMyPage(let screen): switch screen { case .onboardingLogin: - return .send(.switchFlow(.onboardingFlow)) + return .send(.switchFlow(.onboardingFlow(.init()))) } } diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectedTraineeProfileView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectedTraineeProfileView.swift index 88c79bab..577bd74c 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectedTraineeProfileView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectedTraineeProfileView.swift @@ -25,32 +25,30 @@ public struct ConnectedTraineeProfileView: View { } public var body: some View { - NavigationStack { - ZStack { - Image(.imgConnectionCompleteBackground) - .resizable() - .scaledToFill() - .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) - .clipped() - .ignoresSafeArea() + ZStack { + Image(.imgConnectionCompleteBackground) + .resizable() + .scaledToFill() + .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) + .clipped() + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() - VStack(spacing: 0) { - Spacer() - - traineeView() - - Spacer() - - TBottomButton(title: "시작하기", isEnable: true) { - send(.startButtonTapped) - } - .padding(.bottom, .safeAreaBottom) - .ignoresSafeArea(.all, edges: .bottom) + traineeView() + + Spacer() + + TBottomButton(title: "시작하기", isEnable: true) { + send(.startButtonTapped) } + .padding(.bottom, .safeAreaBottom) + .ignoresSafeArea(.all, edges: .bottom) } - .navigationBarBackButtonHidden() } .background(Color.neutral800) + .navigationBarBackButtonHidden() } @ViewBuilder diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteFeature.swift index 9e1f3985..6eb45c0f 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteFeature.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteFeature.swift @@ -15,14 +15,14 @@ import Domain public struct ConnectionCompleteFeature { @ObservableState public struct State: Equatable { - var traineeId: Int? - var trainerId: Int? + var traineeId: Int64? + var trainerId: Int64? var connectionInfo: ConnectionInfoEntity? var traineeProfile: ConnectedTraineeProfileEntity? public init( - traineeId: Int? = nil, - trainerId: Int? = nil, + traineeId: Int64? = nil, + trainerId: Int64? = nil, connectionInfo: ConnectionInfoEntity? = nil, traineeProfile: ConnectedTraineeProfileEntity? = nil ) { diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteView.swift index 66f4ed69..9dc00f9c 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainer/Login/Connection/ConnectionCompleteView.swift @@ -53,6 +53,7 @@ public struct ConnectionCompleteView: View { .ignoresSafeArea(.all, edges: .bottom) } } + .onAppear { send(.onAppear) } .navigationBarBackButtonHidden() } diff --git a/TnT/Projects/TnTApp/Sources/Application/AppDelegate.swift b/TnT/Projects/TnTApp/Sources/Application/AppDelegate.swift index 77ead4ef..19e5b4dd 100644 --- a/TnT/Projects/TnTApp/Sources/Application/AppDelegate.swift +++ b/TnT/Projects/TnTApp/Sources/Application/AppDelegate.swift @@ -12,6 +12,7 @@ import FirebaseMessaging import UserNotifications import Data +import Domain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -60,6 +61,31 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { completionHandler([.sound, .badge, .list, .banner]) } + + /// fore/background 알림 탭했을 때 처리 + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + + if let trainerIdString = userInfo["trainerId"] as? String, + let trainerId = Int64(trainerIdString), + let traineeIdString = userInfo["traineeId"] as? String, + let traineeId = Int64(traineeIdString) { + NotificationCenter.default.post( + name: NSNotification.Name.fcmUserConnectedNotification, + object: nil, + userInfo: [ + "trainerId": trainerId, + "traineeId": traineeId + ] + ) + } + + completionHandler() + } } // MARK: - MessagingDelegate (FCM 토큰 가져오기) @@ -79,3 +105,12 @@ extension AppDelegate: MessagingDelegate { print("✅ FCM 등록 토큰: \(fcmToken)") } } + +extension AppDelegate { + // SceneDelegate + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + sceneConfig.delegateClass = SceneDelegate.self + return sceneConfig + } +} diff --git a/TnT/Projects/TnTApp/Sources/Application/SceneDelegate.swift b/TnT/Projects/TnTApp/Sources/Application/SceneDelegate.swift index de020cf7..c1b3d790 100644 --- a/TnT/Projects/TnTApp/Sources/Application/SceneDelegate.swift +++ b/TnT/Projects/TnTApp/Sources/Application/SceneDelegate.swift @@ -7,13 +7,32 @@ // import UIKit +import Domain class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard (scene is UIWindowScene) else { return } + if let notificationResponse = connectionOptions.notificationResponse { + let userInfo = notificationResponse.notification.request.content.userInfo + + if let trainerIdString = userInfo["trainerId"] as? String, + let trainerId = Int64(trainerIdString), + let traineeIdString = userInfo["traineeId"] as? String, + let traineeId = Int64(traineeIdString) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + NotificationCenter.default.post( + name: NSNotification.Name.fcmUserConnectedNotification, + object: nil, + userInfo: [ + "trainerId": trainerId, + "traineeId": traineeId + ] + ) + } + } + } } func sceneDidDisconnect(_ scene: UIScene) {