diff --git a/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift b/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift index cfde03bf..749c2157 100644 --- a/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift +++ b/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift @@ -50,4 +50,9 @@ public struct UserRepositoryImpl: UserRepository { public func postWithdrawal() async throws -> PostWithdrawalResDTO { return try await networkService.request(UserTargetType.postWithdrawal, decodingType: PostWithdrawalResDTO.self) } + + /// 마이페이지 요청을 수행 + public func getMyPageInfo() async throws -> GetMyPageInfoResDTO { + return try await networkService.request(UserTargetType.getMyPageInfo, decodingType: GetMyPageInfoResDTO.self) + } } diff --git a/TnT/Projects/Data/Sources/Network/Service/User/UserTargetType.swift b/TnT/Projects/Data/Sources/Network/Service/User/UserTargetType.swift index 0d6e50be..3c365e37 100644 --- a/TnT/Projects/Data/Sources/Network/Service/User/UserTargetType.swift +++ b/TnT/Projects/Data/Sources/Network/Service/User/UserTargetType.swift @@ -22,6 +22,8 @@ public enum UserTargetType { case postLogout /// 회원 탈퇴 요청 case postWithdrawal + /// 마이페이지 정보 요청 + case getMyPageInfo } extension UserTargetType: TargetType { @@ -45,12 +47,15 @@ extension UserTargetType: TargetType { case .postWithdrawal: return "/members/withdraw" + + case .getMyPageInfo: + return "/members" } } var method: HTTPMethod { switch self { - case .getSessionCheck: + case .getSessionCheck, .getMyPageInfo: return .get case .postSocialLogin, .postSignUp, .postLogout, .postWithdrawal: @@ -60,7 +65,7 @@ extension UserTargetType: TargetType { var task: RequestTask { switch self { - case .getSessionCheck, .postLogout, .postWithdrawal: + case .getSessionCheck, .postLogout, .postWithdrawal, .getMyPageInfo: return .requestPlain case .postSocialLogin(let reqDto): @@ -80,7 +85,7 @@ extension UserTargetType: TargetType { var headers: [String: String]? { switch self { - case .getSessionCheck, .postLogout, .postWithdrawal: + case .getSessionCheck, .postLogout, .postWithdrawal, .getMyPageInfo: return nil case .postSocialLogin: @@ -96,7 +101,7 @@ extension UserTargetType: TargetType { var interceptors: [any Interceptor] { switch self { - case .getSessionCheck, .postLogout, .postWithdrawal: + case .getSessionCheck, .postLogout, .postWithdrawal, .getMyPageInfo: return [ LoggingInterceptor(), AuthTokenInterceptor(), diff --git a/TnT/Projects/Domain/Sources/DTO/User/UserResponseDTO.swift b/TnT/Projects/Domain/Sources/DTO/User/UserResponseDTO.swift index de90cb6c..c9bc49f1 100644 --- a/TnT/Projects/Domain/Sources/DTO/User/UserResponseDTO.swift +++ b/TnT/Projects/Domain/Sources/DTO/User/UserResponseDTO.swift @@ -10,6 +10,9 @@ import Foundation /// 로그인 세션 유효 확인 응답 DTO public struct GetSessionCheckResDTO: Decodable { + /// 트레이너/트레이니 연결 여부 + public let isConnected: Bool + /// 멤버 유형 public let memberType: MemberTypeResDTO } @@ -104,6 +107,50 @@ public struct PostLogoutResDTO: Decodable { /// 회원탈퇴 응답 DTO public typealias PostWithdrawalResDTO = EmptyResponse +/// 마이페이지 정보 응답 DTO +public struct GetMyPageInfoResDTO: Decodable { + /// 회원 이름 + public let name: String + /// 이메일 + public let email: String + /// 프로필 사진 URL + public let profileImageUrl: String + /// 회원 타입 + public let memberType: MemberTypeResDTO + /// 소셜 타입 + public let socialType: String + /// 트레이너 DTO + public let trainer: TrainerInfoResDTO? + /// 트레이니 DTO + public let trainee: TraineeInfoResDTO? +} + +/// 트레이너 정보 표시에 사용되는 TrainerInfoDTO +public struct TrainerInfoResDTO: Decodable { + /// 관리 중인 회원 + public let activeTraineeCount: Int? + /// 함께했던 회원 + public let totalTraineeCount: Int? +} + +/// 트레이니 정보 표시에 사용되는 TraineeInfoDTO +public struct TraineeInfoResDTO: Decodable { + /// 트레이너 연결 여부 + public let isConnected: Bool + /// 생년월일 + public let birthday: String? + /// 나이 + public let age: Int? + /// 진행한 PT 횟수 + public let height: Double? + /// 총 PT 횟수 + public let weight: Double? + /// 주의사항 + public let cautionNote: String? + /// PT 목표 + public let ptGoals: [String] +} + public extension PostSignUpResDTO { func toEntity() -> PostSignUpResEntity { return .init( diff --git a/TnT/Projects/Domain/Sources/Entity/MyPageEntity.swift b/TnT/Projects/Domain/Sources/Entity/MyPageEntity.swift new file mode 100644 index 00000000..b46eb8e4 --- /dev/null +++ b/TnT/Projects/Domain/Sources/Entity/MyPageEntity.swift @@ -0,0 +1,33 @@ +// +// MyPageEntity.swift +// Domain +// +// Created by 박민서 on 2/12/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +public struct TraineeMyPageEntity: Equatable, Sendable { + /// 트레이니 연결 여부 + public let isConnected: Bool + /// 트레이니 이름 + public let name: String + /// 트레이니 프로필 이미지 URL + public let profileImageUrl: String + /// 소셜 타입 + public let socialType: String +} + +public struct TrainerMyPageEntity: Equatable, Sendable { + /// 트레이너 이름 + public let name: String + /// 트레이너 프로필 이미지 URL + public let profileImageUrl: String + /// 소셜 타입 + public let socialType: String + /// 관리 중인 회원 수 + public let activeTraineeCount: Int? + /// 함께했던 회원 수 + public let totalTraineeCount: Int? +} diff --git a/TnT/Projects/Domain/Sources/Mapper/UserMapper.swift b/TnT/Projects/Domain/Sources/Mapper/UserMapper.swift new file mode 100644 index 00000000..80b8d61d --- /dev/null +++ b/TnT/Projects/Domain/Sources/Mapper/UserMapper.swift @@ -0,0 +1,30 @@ +// +// UserMapper.swift +// Domain +// +// Created by 박민서 on 2/12/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +public extension GetMyPageInfoResDTO { + func toEntity() -> TraineeMyPageEntity { + return .init( + isConnected: self.trainee?.isConnected ?? false, + name: self.name, + profileImageUrl: self.profileImageUrl, + socialType: self.socialType + ) + } + + func toEntity() -> TrainerMyPageEntity { + return .init( + name: self.name, + profileImageUrl: self.profileImageUrl, + socialType: self.socialType, + activeTraineeCount: self.trainer?.activeTraineeCount, + totalTraineeCount: self.trainer?.totalTraineeCount + ) + } +} diff --git a/TnT/Projects/Domain/Sources/Policy/AppStorage.swift b/TnT/Projects/Domain/Sources/Policy/AppStorage.swift index 19971aaa..9c5a059f 100644 --- a/TnT/Projects/Domain/Sources/Policy/AppStorage.swift +++ b/TnT/Projects/Domain/Sources/Policy/AppStorage.swift @@ -10,4 +10,5 @@ import Foundation public enum AppStorage { public static let hideHomePopupUntil: String = "hideHomePopupUntil" + public static let isConnected: String = "isConnected" } diff --git a/TnT/Projects/Domain/Sources/Repository/UserRepository.swift b/TnT/Projects/Domain/Sources/Repository/UserRepository.swift index d30daf06..f4964f39 100644 --- a/TnT/Projects/Domain/Sources/Repository/UserRepository.swift +++ b/TnT/Projects/Domain/Sources/Repository/UserRepository.swift @@ -39,4 +39,9 @@ public protocol UserRepository { /// - Returns: 회원 탈퇴 완료 시 응답 DTO (`PostWithdrawalResDTO`) /// - Throws: 네트워크 오류 또는 서버에서 반환한 오류를 발생시킬 수 있음 func postWithdrawal() async throws -> PostWithdrawalResDTO + + /// 마이페이지 정보 요청 + /// - Returns: 마이페이지 표시에 필요한 응답 DTO (`GetMyPageInfoResDTO`) + /// - Throws: 네트워크 오류 또는 서버에서 반환한 오류를 발생시킬 수 있음 + func getMyPageInfo() async throws -> GetMyPageInfoResDTO } diff --git a/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift b/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift index 534c1224..1ab32a58 100644 --- a/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift +++ b/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift @@ -28,7 +28,7 @@ public protocol UserUseCase { } // MARK: - Default 구현체 -public struct DefaultUserUseCase: UserRepository, UserUseCase { +public struct DefaultUserUseCase: UserUseCase { public let userRepostiory: UserRepository @@ -67,7 +67,7 @@ public struct DefaultUserUseCase: UserRepository, UserUseCase { } // MARK: - Repository -extension DefaultUserUseCase { +extension DefaultUserUseCase: UserRepository { public func getSessionCheck() async throws -> GetSessionCheckResDTO { return try await userRepostiory.getSessionCheck() } @@ -87,4 +87,8 @@ extension DefaultUserUseCase { public func postWithdrawal() async throws -> PostWithdrawalResDTO { return try await userRepostiory.postWithdrawal() } + + public func getMyPageInfo() async throws -> GetMyPageInfoResDTO { + return try await userRepostiory.getMyPageInfo() + } } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift index 81b9930e..707c5313 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/AppFlow/AppFlowCoordinatorFeature.swift @@ -23,9 +23,14 @@ public struct AppFlowCoordinatorFeature { @ObservableState public struct State: Equatable { // MARK: Data related state + /// 트레이너/트레이니 연결 여부 + @Shared(.appStorage(AppStorage.isConnected)) var isConnected: Bool = false + /// 유저 멤버 유형 var userType: UserType? // MARK: UI related state + /// 스플래시 표시 여부 var view_isSplashActive: Bool + /// 팝업 표시 여부 var view_isPopUpPresented: Bool // MARK: SubFeature state @@ -59,7 +64,7 @@ public struct AppFlowCoordinatorFeature { /// 저장 세션 정보 확인 case checkSessionInfo /// 현재 유저 정보 업데이트 - case updateUserInfo(UserType?) + case updateUserInfo(type: UserType?, isConnected: Bool) /// 스플래시 표시 종료 시 case splashFinished /// 세션 만료 팝업 표시 @@ -139,15 +144,20 @@ public struct AppFlowCoordinatorFeature { switch action { case .checkSession: return .run { send in - let result = try? await userUseCaseRepo.getSessionCheck() - switch result?.memberType { + guard let result = try? await userUseCaseRepo.getSessionCheck() else { + try keyChainManager.delete(.sessionId) + await send(.updateUserInfo(type: nil, isConnected: false)) + return + } + + switch result.memberType { case .trainer: - await send(.updateUserInfo(.trainer)) + await send(.updateUserInfo(type: .trainer, isConnected: result.isConnected)) case .trainee: - await send(.updateUserInfo(.trainee)) + await send(.updateUserInfo(type: .trainee, isConnected: result.isConnected)) default: try keyChainManager.delete(.sessionId) - await send(.updateUserInfo(nil)) + await send(.updateUserInfo(type: nil, isConnected: false)) } } } @@ -156,9 +166,11 @@ public struct AppFlowCoordinatorFeature { let session: String? = try? keyChainManager.read(for: .sessionId) return session != nil ? .send(.api(.checkSession)) - : .send(.updateUserInfo(nil)) + : .send(.updateUserInfo(type: nil, isConnected: false)) + + case let .updateUserInfo(userType, isConnected): + state.$isConnected.withLock { $0 = isConnected } - case .updateUserInfo(let userType): switch userType { case .trainee: return self.setFlow(.traineeMainFlow, state: &state) diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift index 8e8dbc05..3cf575d7 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TraineeMainFlow/TraineeMainFlowFeature.swift @@ -114,6 +114,11 @@ public struct TraineeMainFlowFeature { ) return .none + /// 연결 완료 화면 -> 홈으로 이동 + case .element(id: _, action: .traineeConnectionComplete(.setNavigating)): + state.path.removeLast(2) + return .none + default: return .none } diff --git a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift index 6b1cd017..069a0d1c 100644 --- a/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift +++ b/TnT/Projects/Presentation/Sources/Coordinator/TrainerMainFlow/TrainerMainFlowFeature.swift @@ -71,10 +71,16 @@ public struct TrainerMainFlowFeature { state.path.removeLast() return .none + /// 연결 완료 -> 트레이니 정보 case .element(id: _, action: .connectionComplete(.setNavigating(let profile))): state.path.append(.connectedTraineeProfile(.init(traineeProfile: profile))) return .none + /// 트레이니 정보 -> 홈으로 + case .element(id: _, action: .connectedTraineeProfile(.setNavigating)): + state.path.removeLast(2) + return.none + /// 트레이너 수업 추가 -> 홈으로 case .element(id: _, action: .addPTSession(.setNavigating)): state.path.removeLast() diff --git a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift index 9de4d833..19fb1e39 100644 --- a/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift +++ b/TnT/Projects/Presentation/Sources/Home/Trainee/TraineeHomeFeature.swift @@ -19,6 +19,8 @@ public struct TraineeHomeFeature { // MARK: Data related state /// 3일 동안 보지 않기 시작 날짜 @Shared(.appStorage(AppStorage.hideHomePopupUntil)) var hidePopupUntil: Date? + /// 트레이너 연결 여부 + @Shared(.appStorage(AppStorage.isConnected)) var isConnected: Bool = false /// 선택된 날짜 var selectedDate: Date /// 캘린더 이벤트 @@ -29,8 +31,6 @@ public struct TraineeHomeFeature { var records: [RecordListItemEntity] /// 3일 동안 보지 않기 선택되었는지 여부 var isHideUntilSelected: Bool - /// 트레이너 연결 여부 - var isConnected: Bool // MARK: UI related state /// 캘린더 표시 페이지 @@ -55,7 +55,6 @@ public struct TraineeHomeFeature { sessionInfo: WorkoutListItemEntity? = nil, records: [RecordListItemEntity] = [], isHideUntilSelected: Bool = false, - isConnected: Bool = false, view_currentPage: Date = .now, view_isBottomSheetPresented: Bool = false, view_isPopUpPresented: Bool = false @@ -65,7 +64,6 @@ public struct TraineeHomeFeature { self.sessionInfo = sessionInfo self.records = records self.isHideUntilSelected = isHideUntilSelected - self.isConnected = isConnected self.view_currentPage = view_currentPage self.view_isBottomSheetPresented = view_isBottomSheetPresented self.view_isPopUpPresented = view_isPopUpPresented @@ -172,11 +170,9 @@ public struct TraineeHomeFeature { return .send(.setNavigating(.traineeInvitationCodeInput)) case .onAppear: - if let hideUntil = state.hidePopupUntil, hideUntil > Date() { - state.view_isPopUpPresented = false - } else { - state.view_isPopUpPresented = true - } + let hideUntil = state.hidePopupUntil ?? Date() + let hidePopUp = state.isConnected || hideUntil > Date() + state.view_isPopUpPresented = !hidePopUp return .none } diff --git a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift index e170b45f..18a46dab 100644 --- a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift +++ b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageFeature.swift @@ -24,6 +24,8 @@ public struct TraineeMyPageFeature { // MARK: Data related state /// 3일 동안 보지 않기 시작 날짜 @Shared(.appStorage(AppStorage.hideHomePopupUntil)) var hidePopupUntil: Date? + /// 트레이너 연결 여부 + @Shared(.appStorage(AppStorage.isConnected)) var isConnected: Bool = false /// 사용자 이름 var userName: String /// 사용자 이미지 URL @@ -86,6 +88,8 @@ public struct TraineeMyPageFeature { case setAppPushNotificationAllowed(Bool) /// 푸시 알람 허용 시스템 화면 이동 case sendAppPushNotificationSetting + /// 마이페이지 정보 반영 + case setMyPageInfo(TraineeMyPageEntity) /// 팝업 세팅 처리 case setPopUpStatus(PopUp?) /// 네비게이션 여부 설정 @@ -125,6 +129,8 @@ public struct TraineeMyPageFeature { case logout /// 회원 탈퇴 API case withdraw + /// 마이페이지 정보 get API + case myPageInfo } } @@ -148,8 +154,7 @@ public struct TraineeMyPageFeature { return .none case .tapConnectTrainerButton: - print("tapConnectTrainerButton") - return .none + return .send(.setNavigating(.traineeInvitationCodeInput)) case .tapTOSButton: if let url = URL(string: AppLinks.termsOfService) { @@ -206,7 +211,10 @@ public struct TraineeMyPageFeature { } case .onAppear: - return self.getAppPushNotificationAllowed(state: &state, tryToggle: false) + return .merge( + self.getAppPushNotificationAllowed(state: &state, tryToggle: false), + .send(.api(.myPageInfo)) + ) } case .api(let action): @@ -217,14 +225,28 @@ public struct TraineeMyPageFeature { try keyChainManager.delete(.sessionId) await send(.setPopUpStatus(.logoutCompleted)) } + case .withdraw: return .run { send in let result = try await userUseRepoCase.postWithdrawal() try keyChainManager.delete(.sessionId) await send(.setPopUpStatus(.withdrawCompleted)) } + + case .myPageInfo: + return .run { send in + let result = try await userUseRepoCase.getMyPageInfo() + let info: TraineeMyPageEntity = result.toEntity() + await send(.setMyPageInfo(info)) + } } - + + case .setMyPageInfo(let myPageInfo): + state.$isConnected.withLock { $0 = myPageInfo.isConnected } + state.userName = myPageInfo.name + state.userImageUrl = myPageInfo.profileImageUrl + return .none + case .setPopUpStatus(let popUp): state.view_popUp = popUp state.view_isPopUpPresented = popUp != nil diff --git a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift index 5f1b318c..b7db4067 100644 --- a/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift +++ b/TnT/Projects/Presentation/Sources/MyPage/Trainee/TraineeMyPageView.swift @@ -70,7 +70,7 @@ public struct TraineeMyPageView: View { @ViewBuilder private func TopItemSection() -> some View { VStack(spacing: 12) { - if !store.view_isTrainerConnected { + if !store.isConnected { ProfileItemView(title: "트레이너 연결하기", tapAction: { send(.tapConnectTrainerButton) }) .padding(.vertical, 4) .background(Color.common0) diff --git a/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageFeature.swift b/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageFeature.swift index 3c977ec9..8dad7367 100644 --- a/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageFeature.swift +++ b/TnT/Projects/Presentation/Sources/MyPage/Trainer/TrainerMypageFeature.swift @@ -70,6 +70,8 @@ public struct TrainerMypageFeature { case setAppPushNotificationAllowed(Bool) /// 푸시 알람 허용 시스템 화면 이동 case sendAppPushNotificationSetting + /// 마이페이지 정보 반영 + case setMyPageInfo(TrainerMyPageEntity) /// 팝업 세팅 처리 case setPopUpStatus(PopUp?) /// 네비게이션 여부 설정 @@ -103,6 +105,8 @@ public struct TrainerMypageFeature { case logout /// 회원 탈퇴 API case withdraw + /// 마이페이지 정보 get API + case myPageInfo } } @@ -167,7 +171,10 @@ public struct TrainerMypageFeature { } case .onAppear: - return self.getAppPushNotificationAllowed(state: &state, tryToggle: false) + return .merge( + self.getAppPushNotificationAllowed(state: &state, tryToggle: false), + .send(.api(.myPageInfo)) + ) } case .api(let action): @@ -178,14 +185,31 @@ public struct TrainerMypageFeature { try keyChainManager.delete(.sessionId) await send(.setPopUpStatus(.logoutCompleted)) } + case .withdraw: return .run { send in let result = try await userUseRepoCase.postWithdrawal() try keyChainManager.delete(.sessionId) await send(.setPopUpStatus(.withdrawCompleted)) } + + case .myPageInfo: + return .run { send in + let result = try await userUseRepoCase.getMyPageInfo() + let info: TrainerMyPageEntity = result.toEntity() + await send(.setMyPageInfo(info)) + } } + case .setMyPageInfo(let myPageInfo): + state.userName = myPageInfo.name + state.userImageUrl = myPageInfo.profileImageUrl + if let activeCount = myPageInfo.activeTraineeCount, let totalCount = myPageInfo.totalTraineeCount { + state.studentCount = activeCount + state.oldStudentCount = totalCount + } + return .none + case .setPopUpStatus(let popUp): state.view_popUp = popUp state.view_isPopUpPresented = popUp != nil