diff --git a/TnT/Projects/DI/Sources/DISoureDummy.swift b/TnT/Projects/DI/Sources/DISoureDummy.swift deleted file mode 100644 index e2c4516..0000000 --- a/TnT/Projects/DI/Sources/DISoureDummy.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// DISoureDummy.swift -// Packages -// -// Created by 박서연 on 1/4/25. -// - -import Foundation diff --git a/TnT/Projects/DI/Project.swift b/TnT/Projects/DIContainer/Project.swift similarity index 67% rename from TnT/Projects/DI/Project.swift rename to TnT/Projects/DIContainer/Project.swift index 55c30ce..8773fda 100644 --- a/TnT/Projects/DI/Project.swift +++ b/TnT/Projects/DIContainer/Project.swift @@ -8,6 +8,4 @@ @preconcurrency import ProjectDescription @preconcurrency import ProjectDescriptionHelpers -//let project = Project.module(name: "DI", resources: false) - - +let project = Project.staticLibraryProejct(name: "DIContainer", resource: false) diff --git a/TnT/Projects/DIContainer/Sources/DIContainer.swift b/TnT/Projects/DIContainer/Sources/DIContainer.swift new file mode 100644 index 0000000..3f1997e --- /dev/null +++ b/TnT/Projects/DIContainer/Sources/DIContainer.swift @@ -0,0 +1,33 @@ +// +// DIContainer.swift +// DIContainer +// +// Created by 박민서 on 1/27/25. +// + +import Dependencies + +import Domain +import Data + +// MARK: - Swift-Dependencies +private enum UserUseCaseKey: DependencyKey { + static let liveValue: UserUseCase = DefaultUserUseCase(userRepository: UserRepositoryImpl()) +} + +private enum TraineeUseCaseKey: DependencyKey { + static let liveValue: TraineeUseCase = DefaultTraineeUseCase(trainerRepository: TrainerRepositoryImpl()) +} + +// MARK: - DependencyValues +public extension DependencyValues { + var userUseCase: UserUseCase { + get { self[UserUseCaseKey.self] } + set { self[UserUseCaseKey.self] = newValue } + } + + var traineeUseCase: TraineeUseCase { + get { self[TraineeUseCaseKey.self] } + set { self[TraineeUseCaseKey.self] = newValue } + } +} diff --git a/TnT/Projects/Data/Sources/Network/Service/SocialLogin/SNSLoginManager.swift b/TnT/Projects/Data/Sources/Network/Service/SocialLogin/SNSLoginManager.swift new file mode 100644 index 0000000..6d25b3b --- /dev/null +++ b/TnT/Projects/Data/Sources/Network/Service/SocialLogin/SNSLoginManager.swift @@ -0,0 +1,170 @@ +// +// SNSLoginManager.swift +// DomainAuthInterface +// +// Created by 박민서 on 9/19/24. +// + +import AuthenticationServices +import KakaoSDKCommon +import KakaoSDKAuth +import KakaoSDKUser + +import Domain + +/// SNSLoginManager는 카카오, 애플과 같은 소셜 로그인 및 로그아웃을 위한 헬퍼 클래스입니다. +/// 소셜 로그인 관련 SDK, API 호출은 모두 이곳에서 관리됩니다. +public final class SNSLoginManager: NSObject, SocialLoginRepository { + + override public init() { + KakaoSDK.initSDK(appKey: Config.kakaoNativeAppKey) + } + + // 클로저 저장을 위한 프로퍼티 + private var appleLoginCompletion: ((AppleLoginInfo?) -> Void)? + + // 애플 로그인 + public func appleLogin() async -> AppleLoginInfo? { + + // 클로저 기반의 비동기 작업을 async/await 방식으로 변환 + await withCheckedContinuation { continuation in + let appleIDProvider: ASAuthorizationAppleIDProvider = ASAuthorizationAppleIDProvider() + let request: ASAuthorizationAppleIDRequest = appleIDProvider.createRequest() + request.requestedScopes = [.fullName, .email] + + let authorizationController: ASAuthorizationController = ASAuthorizationController(authorizationRequests: [request]) + + authorizationController.delegate = self + authorizationController.presentationContextProvider = self + + self.appleLoginCompletion = { loginInfo in + // 비동기 작업이 끝나면 continuation.resume()으로 결과를 넘깁니다 + continuation.resume(returning: loginInfo) // 로그인 성공 또는 실패 후 반환 + } + + authorizationController.performRequests() + } + } + + // 카카오 로그인 + public func kakaoLogin() async -> KakaoLoginInfo? { + return await loginWithKakao { + // 카카오 앱의 설치 유무에 따라 로그인 처리 + return UserApi.isKakaoTalkLoginAvailable() + ? await self.loginWithKakaoTalk() + : await self.loginWithKakaoWeb() + } + } + + // 카카오 로그아웃 + public func kakaoLogout() async { + if AuthApi.hasToken() { + await withCheckedContinuation { continuation in + UserApi.shared.logout() { error in + if let error { + print("Kakao Logout Failed: \(error.localizedDescription)") + continuation.resume() + } else { + print("Kakao Logout Success") + continuation.resume() + } + } + } + } + } +} + +// MARK: 애플 로그인 Extension 구현 +extension SNSLoginManager: ASAuthorizationControllerDelegate, ASWebAuthenticationPresentationContextProviding, ASAuthorizationControllerPresentationContextProviding { + + // 애플 로그인 성공 시 필요 정보 클로저로 리턴 + public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { + self.appleLoginCompletion?(nil) + return + } + + guard + let identityToken = appleIDCredential.identityToken, + let identityTokenString = String(data: identityToken, encoding: .utf8), + let authorizationCode = appleIDCredential.authorizationCode, + let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) + else { + self.appleLoginCompletion?(nil) + return + } + + self.appleLoginCompletion?( + AppleLoginInfo( + identityToken: identityTokenString, + authorizationCode: authorizationCodeString + ) + ) + } + + // 애플 로그인 실패 시 nil 리턴 + public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + print("Apple login failed: \(error.localizedDescription)") + self.appleLoginCompletion?(nil) + } + + public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return ASPresentationAnchor() + } + + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return ASPresentationAnchor() + } +} + +// MARK: 카카오 로그인 Extension 구현 +extension SNSLoginManager { + + /// 카카오톡(App) 로그인 처리를 async await로 래핑하는 함수입니다 + @MainActor + private func loginWithKakaoTalk() async -> (OAuthToken?, Error?) { + return await withCheckedContinuation { continuation in + UserApi.shared.loginWithKakaoTalk { (oauthToken, error) in + continuation.resume(returning: (oauthToken, error)) + } + } + } + + /// 카카오톡(Web) 로그인 처리를 async await로 래핑하는 함수입니다 + @MainActor + private func loginWithKakaoWeb() async -> (OAuthToken?, Error?) { + return await withCheckedContinuation { continuation in + UserApi.shared.loginWithKakaoAccount { (oauthToken, error) in + continuation.resume(returning: (oauthToken, error)) + } + } + } + + /// 공통된 카카오 로그인 로직을 async/await 방식으로 처리 + /// - Parameters: + /// - loginMethod: 로그인 방식 (카카오톡 또는 웹 로그인을 선택적으로 처리) + /// - Returns: 성공 시 `KakaoLoginInfo`를 반환하고, 실패(에러, 토큰x) 시 nil을 반환합니다. + private func loginWithKakao( + loginMethod: @escaping () async -> (OAuthToken?, Error?) + ) async -> KakaoLoginInfo? { + let (oauthToken, error): (OAuthToken?, Error?) = await loginMethod() + + if let error = error { + print("kakao login failed: \(error.localizedDescription)") + return nil // 에러 발생 시 nil 반환 + } + + guard + let kakaoAccessToken = oauthToken?.accessToken, + let kakaoRefreshToken = oauthToken?.refreshToken + else { + return nil // 토큰이 없을 시 nil 반환 + } + + return KakaoLoginInfo( + accessToken: kakaoAccessToken, + socialRefreshToken: kakaoRefreshToken + ) + } +} diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift new file mode 100644 index 0000000..53a8ced --- /dev/null +++ b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerRepositoryImpl.swift @@ -0,0 +1,26 @@ +// +// TrainerRepositoryImpl.swift +// Data +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation +import Dependencies + +import Domain + +/// 트레이너 관련 네트워크 요청을 처리하는 TrainerRepository 구현체 +public struct TrainerRepositoryImpl: TrainerRepository { + private let networkService: NetworkService = .shared + + public init() {} + + public func getVerifyInvitationCode(code: String) async throws -> GetVerifyInvitationCodeResDTO { + return try await networkService.request( + TrainerTargetType.getVerifyInvitationCode(code: code), + decodingType: GetVerifyInvitationCodeResDTO.self + ) + } +} diff --git a/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift new file mode 100644 index 0000000..1e1730c --- /dev/null +++ b/TnT/Projects/Data/Sources/Network/Service/Trainer/TrainerTargetType.swift @@ -0,0 +1,61 @@ +// +// TrainerTargetType.swift +// Data +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +import Domain + +/// 사용자 관련 API 요청 타입 정의 +public enum TrainerTargetType { + /// 트레이너 초대코드 인증 + case getVerifyInvitationCode(code: String) +} + +extension TrainerTargetType: TargetType { + var baseURL: URL { + let url: String = Config.apiBaseUrlDev + "/trainers" + return URL(string: url)! + } + + var path: String { + switch self { + case .getVerifyInvitationCode(let code): + return "/invitation-code/verify/\(code)" + } + } + + var method: HTTPMethod { + switch self { + case .getVerifyInvitationCode: + return .get + } + } + + var task: RequestTask { + switch self { + case .getVerifyInvitationCode: + return .requestPlain + } + } + + var headers: [String: String]? { + switch self { + case .getVerifyInvitationCode: + return ["Content-Type": "application/json"] + } + } + + var interceptors: [any Interceptor] { + return [ + LoggingInterceptor(), + AuthTokenInterceptor(), + ResponseValidatorInterceptor(), + RetryInterceptor(maxRetryCount: 0) + ] + } +} diff --git a/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift b/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift index bd3daab..34ef8b2 100644 --- a/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift +++ b/TnT/Projects/Data/Sources/Network/Service/User/UserRepositoryImpl.swift @@ -37,16 +37,3 @@ public struct UserRepositoryImpl: UserRepository { ) } } - -// MARK: - Swift-Dependencies -private enum UserRepositoryKey: DependencyKey { - static let liveValue: UserRepository = UserRepositoryImpl() -} - -// MARK: - DependencyValues -public extension DependencyValues { - var userRepository: UserRepository { - get { self[UserRepositoryKey.self] } - set { self[UserRepositoryKey.self] = newValue } - } -} diff --git a/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextField.swift b/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextField.swift index 29bd5fd..484ff49 100644 --- a/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextField.swift +++ b/TnT/Projects/DesignSystem/Sources/Components/TextField/TTextField.swift @@ -73,7 +73,7 @@ public struct TTextField: View { public extension TTextField.RightView { enum Style { case unit(text: String, status: TTextField.Status) - case button(title: String, tapAction: () -> Void) + case button(title: String, state: TButton.ButtonState, tapAction: () -> Void) } } @@ -96,17 +96,15 @@ public extension TTextField { .padding(.horizontal, 12) .padding(.vertical, 3) - case let .button(title, tapAction): + case let .button(title, state, tapAction): // TODO: 추후 버튼 컴포넌트 나오면 대체 - Button(action: tapAction) { - Text(title) - .typographyStyle(.label2Medium, with: .neutral50) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Color.neutral900) - .clipShape(.rect(cornerRadius: 8)) - } - .padding(.vertical, 5) + TButton( + title: title, + config: .small, + state: state, + action: tapAction + ) + .frame(width: 66) } } } diff --git a/TnT/Projects/Domain/Sources/DTO/SocialLogin/OAuthDTO.swift b/TnT/Projects/Domain/Sources/DTO/SocialLogin/OAuthDTO.swift new file mode 100644 index 0000000..e1c7c0e --- /dev/null +++ b/TnT/Projects/Domain/Sources/DTO/SocialLogin/OAuthDTO.swift @@ -0,0 +1,31 @@ +// +// OAuthDTO.swift +// Domain +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +/// 카카오 로그인 요청 DTO +public struct KakaoLoginInfo: Codable { + public let accessToken: String + public let socialRefreshToken: String + + public init(accessToken: String, socialRefreshToken: String) { + self.accessToken = accessToken + self.socialRefreshToken = socialRefreshToken + } +} + +/// 애플 로그인 요청 DTO +public struct AppleLoginInfo: Codable { + public let identityToken: String + public let authorizationCode: String + + public init(identityToken: String, authorizationCode: String) { + self.identityToken = identityToken + self.authorizationCode = authorizationCode + } +} diff --git a/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerRequestDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerRequestDTO.swift new file mode 100644 index 0000000..160effe --- /dev/null +++ b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerRequestDTO.swift @@ -0,0 +1,9 @@ +// +// TrainerRequestDTO.swift +// Domain +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation diff --git a/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerResponseDTO.swift b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerResponseDTO.swift new file mode 100644 index 0000000..45525c0 --- /dev/null +++ b/TnT/Projects/Domain/Sources/DTO/Trainer/TrainerResponseDTO.swift @@ -0,0 +1,15 @@ +// +// TrainerResponseDTO.swift +// Domain +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +/// 초대코드 인증하기 응답 DTO +public struct GetVerifyInvitationCodeResDTO: Decodable { + /// 초대 코드 인증 여부 + public let isVerified: Bool +} diff --git a/TnT/Projects/Domain/Sources/Policy/UserPolicy.swift b/TnT/Projects/Domain/Sources/Policy/UserPolicy.swift index 78cc928..ba778dd 100644 --- a/TnT/Projects/Domain/Sources/Policy/UserPolicy.swift +++ b/TnT/Projects/Domain/Sources/Policy/UserPolicy.swift @@ -43,6 +43,12 @@ struct UserPolicy { textValidation: { $0.count <= maxPrecautionLength }, isRequired: false ) + + /// 초대코드 입력 검증( + static let invitationInput: InputInfo = .init( + textValidation: { TextValidator.isValidInput($0, maxLength: 8, regexPattern: "^[A-Z0-9]{8}$") }, + isRequired: true + ) } extension UserPolicy { diff --git a/TnT/Projects/Domain/Sources/Repository/SocialLoginRepository.swift b/TnT/Projects/Domain/Sources/Repository/SocialLoginRepository.swift new file mode 100644 index 0000000..57e0aab --- /dev/null +++ b/TnT/Projects/Domain/Sources/Repository/SocialLoginRepository.swift @@ -0,0 +1,19 @@ +// +// SocialLoginRepository.swift +// Domain +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +/// SNSLoginManager의 인터페이스 프로토콜입니다. +public protocol SocialLoginRepository { + /// 애플 로그인을 수행합니다 + func appleLogin() async -> AppleLoginInfo? + /// 카카오 로그인을 수행합니다 + func kakaoLogin() async -> KakaoLoginInfo? + /// 카카오 로그아웃을 수행합니다 + func kakaoLogout() async +} diff --git a/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift new file mode 100644 index 0000000..7966a33 --- /dev/null +++ b/TnT/Projects/Domain/Sources/Repository/TrainerRepository.swift @@ -0,0 +1,19 @@ +// +// TrainerRepository.swift +// Domain +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation + +/// 트레이너 관련 데이터를 관리하는 `TrainerRepository` 프로토콜 +/// - 실제 네트워크 요청은 이 인터페이스를 구현한 `TrainerRepositoryImpl`에서 수행됩니다. +public protocol TrainerRepository { + /// 트레이너 초대 코드 검증 요청 + /// - Parameter code: 초대 코드 (영어 대문자 + 숫자 조합, 최대 8자) + /// - Returns: 검증 성공 시, 초대 코드 정보가 포함된 응답 DTO (`GetVerifyInvitationCodeResDTO`) + /// - Throws: 네트워크 오류 또는 유효하지 않은 초대 코드로 인한 서버 오류 발생 가능 + func getVerifyInvitationCode(code: String) async throws -> GetVerifyInvitationCodeResDTO +} diff --git a/TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift b/TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift new file mode 100644 index 0000000..148f372 --- /dev/null +++ b/TnT/Projects/Domain/Sources/UseCase/TraineeUseCase.swift @@ -0,0 +1,35 @@ +// +// TraineeUseCase.swift +// Domain +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +// MARK: - TraineeUseCase 프로토콜 +public protocol TraineeUseCase { + /// 입력 초대 코드 검증 + func validateInvitationCode(_ code: String) -> Bool + + // MARK: API Call + /// API Call - 트레이너 초대 코드 인증 API 호출 + func verifyTrainerInvitationCode(_ code: String) async throws -> Bool +} + +// MARK: - Default 구현체 +public struct DefaultTraineeUseCase: TraineeUseCase { + private let trainerRepository: TrainerRepository + + public init(trainerRepository: TrainerRepository) { + self.trainerRepository = trainerRepository + } + + public func validateInvitationCode(_ code: String) -> Bool { + return !code.isEmpty && UserPolicy.invitationInput.textValidation(code) + } + + public func verifyTrainerInvitationCode(_ code: String) async throws -> Bool { + let result = try await trainerRepository.getVerifyInvitationCode(code: code) + return result.isVerified + } +} diff --git a/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift b/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift index 1033632..852f977 100644 --- a/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift +++ b/TnT/Projects/Domain/Sources/UseCase/UserUseCase.swift @@ -28,27 +28,31 @@ public protocol UserUseCase { // MARK: - Default 구현체 public struct DefaultUserUseCase: UserUseCase { - public init() {} + private let userRepository: UserRepository + + public init(userRepository: UserRepository) { + self.userRepository = userRepository + } public func validateUserName(_ name: String) -> Bool { return !name.isEmpty && UserPolicy.userNameInput.textValidation(name) } - + public func validateBirthDate(_ birthDate: String) -> Bool { return birthDate.isEmpty || UserPolicy.birthDateInput.textValidation(birthDate) } - + public func validateHeight(_ height: String) -> Bool { return !height.isEmpty && UserPolicy.heightInput.textValidation(height) } - + public func validateWeight(_ weight: String) -> Bool { return !weight.isEmpty && UserPolicy.weightInput.textValidation(weight) } public func validatePrecaution(_ text: String) -> Bool { - return UserPolicy.precautionInput.textValidation(text) - } + return UserPolicy.precautionInput.textValidation(text) + } public func getMaxNameLength() -> Int { return UserPolicy.maxNameLength @@ -58,16 +62,3 @@ public struct DefaultUserUseCase: UserUseCase { return UserPolicy.maxPrecautionLength } } - -// MARK: - Swift-Dependencies -private enum UserUseCaseKey: DependencyKey { - static let liveValue: UserUseCase = DefaultUserUseCase() -} - -// MARK: - DependencyValues -public extension DependencyValues { - var userUseCase: UserUseCase { - get { self[UserUseCaseKey.self] } - set { self[UserUseCaseKey.self] = newValue } - } -} diff --git a/TnT/Projects/Presentation/Sources/Onboarding/CreateProfile/CreateProfileView.swift b/TnT/Projects/Presentation/Sources/Onboarding/CreateProfile/CreateProfileView.swift index c2b18ef..bb3d977 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/CreateProfile/CreateProfileView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/CreateProfile/CreateProfileView.swift @@ -18,6 +18,7 @@ import DesignSystem public struct CreateProfileView: View { @Bindable public var store: StoreOf + @Environment(\.dismiss) var dismiss: DismissAction /// `CreateProfileView`의 생성자 /// - Parameter store: `CreateProfileFeature`의 상태를 관리하는 `Store` @@ -26,7 +27,10 @@ public struct CreateProfileView: View { } public var body: some View { - VStack { + VStack(spacing: 0) { + TNavigation(type: .LButton(leftImage: .icnArrowLeft), leftAction: { + dismiss() + }) Header() diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputFeature.swift index ec3fed3..d664b9f 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputFeature.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputFeature.swift @@ -6,7 +6,7 @@ // Copyright © 2025 yapp25thTeamTnT. All rights reserved. // -import UIKit +import Foundation import ComposableArchitecture import Domain @@ -70,7 +70,7 @@ public struct TraineeBasicInfoInputFeature { } } - @Dependency(\.userUseCase) private var userUseCase + @Dependency(\.userUseCase) private var userUseCase: UserUseCase public enum Action: Sendable, ViewAction { /// 뷰에서 발생한 액션을 처리합니다. diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputView.swift index 4c34d71..0bbdd37 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeBasicInfoInput/TraineeBasicInfoInputView.swift @@ -67,6 +67,7 @@ public struct TraineeBasicInfoInputView: View { } } + // MARK: - Sections @ViewBuilder private func Header() -> some View { VStack(spacing: 12) { diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift new file mode 100644 index 0000000..e113732 --- /dev/null +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputFeature.swift @@ -0,0 +1,143 @@ +// +// TraineeInvitationCodeInputFeature.swift +// Presentation +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation +import ComposableArchitecture + +import Domain +import DesignSystem +import DIContainer + +@Reducer +public struct TraineeInvitationCodeInputFeature { + + @ObservableState + public struct State: Equatable { + // MARK: Data related state + /// 입력된 초대코드 + var invitationCode: String + + // MARK: UI related state + /// 텍스트 필드 상태 (빈 값 / 입력됨 / 유효하지 않음) + var view_invitationCodeStatus: TTextField.Status + /// 텍스트 필드 푸터 텍스트 + var view_textFieldFooterText: String + /// 현재 텍스트필드 포커스 + var view_isFieldFocused: Bool + /// 인증하기 버튼 활성화 상태 + var view_isVerityButtonEnabled: Bool + /// 다음 버튼 활성화 여부 + var view_isNextButtonEnabled: Bool + + /// `TraineeBasicInfoInputFeature.State`의 생성자 + /// - Parameters: + public init( + invitationCode: String = "", + view_invitationCodeStatus: TTextField.Status = .empty, + view_textFieldFooterText: String = "", + view_isFieldFocused: Bool = false, + view_isVerityButtonEnabled: Bool = false, + view_isNextButtonEnabled: Bool = false + ) { + self.invitationCode = invitationCode + self.view_invitationCodeStatus = view_invitationCodeStatus + self.view_textFieldFooterText = view_textFieldFooterText + self.view_isFieldFocused = view_isFieldFocused + self.view_isVerityButtonEnabled = view_isVerityButtonEnabled + self.view_isNextButtonEnabled = view_isNextButtonEnabled + } + } + + @Dependency(\.traineeUseCase) private var traineeUseCase: TraineeUseCase + + public enum Action: Sendable, ViewAction { + /// 뷰에서 발생한 액션을 처리합니다. + case view(View) + /// 네비게이션 여부 설정 + case setNavigating + /// 다음 버튼 활성화 상태 조작 + case updateVerificationStatus(Bool) + + @CasePathable + public enum View: Sendable, BindableAction { + /// 바인딩할 액션을 처리 + case binding(BindingAction) + /// 인증하기 버튼이 눌렸을 때 + case tapVerifyButton + /// "다음으로" 버튼이 눌렸을 때 + case tapNextButton + /// 포커스 상태 변경 + case setFocus(Bool) + } + } + + public init() {} + + public var body: some ReducerOf { + BindingReducer(action: \.view) + + Reduce { state, action in + switch action { + case .view(let action): + switch action { + case .binding(\.invitationCode): + return self.validateInput(&state) + + case .binding: + return .none + + case .tapVerifyButton: + return .run { [state] send in + let result: Bool = try await traineeUseCase.verifyTrainerInvitationCode(state.invitationCode) + await send(.updateVerificationStatus(result)) + } + + case .tapNextButton: + return .send(.setNavigating) + + case .setFocus(let isFocused): + state.view_isFieldFocused = isFocused + return .none + } + + case .updateVerificationStatus(let isVerified): + state.view_textFieldFooterText = isVerified ? "인증에 성공했습니다" : "인증에 실패했습니다" + state.view_invitationCodeStatus = isVerified ? .valid : .invalid + state.view_isNextButtonEnabled = isVerified + return .none + + case .setNavigating: + return .none + } + } + } +} + +// MARK: Internal Logic +private extension TraineeInvitationCodeInputFeature { + /// 입력값을 검증하고 상태를 업데이트 + func validateInput(_ state: inout State) -> Effect { + // 초대 코드가 비어있는 경우 (버튼 비활성화) + guard !state.invitationCode.isEmpty else { + state.view_textFieldFooterText = "" + state.view_invitationCodeStatus = .empty + state.view_isVerityButtonEnabled = false + return .none + } + state.view_invitationCodeStatus = .filled + + // 초대 코드 형식이 유효한지 검사 + guard traineeUseCase.validateInvitationCode(state.invitationCode) else { + state.view_isVerityButtonEnabled = false + return .none + } + state.view_isVerityButtonEnabled = true + + return .none + } +} diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift new file mode 100644 index 0000000..b6cf9df --- /dev/null +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeInvitationCodeInput/TraineeInvitationCodeInputView.swift @@ -0,0 +1,98 @@ +// +// TraineeInvitationCodeInputView.swift +// Presentation +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import Domain +import DesignSystem + +/// 트레이너 초대 코드를 입력하는 화면 +@ViewAction(for: TraineeInvitationCodeInputFeature.self) +public struct TraineeInvitationCodeInputView: View { + + @Bindable public var store: StoreOf + @FocusState private var focusedField: Bool + + /// `TraineeInvitationCodeInputView` 생성자 + /// - Parameter store: `TraineeInvitationCodeInputFeature`와 연결된 Store + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + // TODO: #30 PR 머지 후 RTextWithTitle로 변경 + TNavigation(type: .LRButtonWithTitle(leftImage: .icnListFilled, centerTitle: "연결하기", rightImage: .icnListFilled), rightAction: { + + }) + .padding(.bottom, 24) + + Header() + .padding(.bottom, 48) + + TextFieldSection() + + Spacer() + } + .navigationBarBackButtonHidden() + .keyboardDismissOnTap() + .safeAreaInset(edge: .bottom) { + if store.view_isFieldFocused == false { + TBottomButton( + title: "다음", + isEnable: store.view_isNextButtonEnabled + ) { + send(.tapNextButton) + } + } + } + .onChange(of: focusedField) { oldValue, newValue in + if oldValue != newValue { + send(.setFocus(newValue)) + } + } + } + + // MARK: - Sections + @ViewBuilder + private func Header() -> some View { + TInfoTitleHeader(title: "트레이너에게 받은\n초대 코드를 입력해 주세요") + } + + @ViewBuilder + private func TextFieldSection() -> some View { + VStack(spacing: 48) { + TTextField( + placeholder: "코드를 입력해주세요", + text: $store.invitationCode, + textFieldStatus: $store.view_invitationCodeStatus, + rightView: { + TTextField.RightView( + style: .button( + title: "인증하기", + state: store.view_isVerityButtonEnabled + ? .default(.primary(isEnabled: true)) + : .disable(.primary(isEnabled: false)), + tapAction: { + send(.tapVerifyButton) + } + ) + ) + } + ) + .withSectionLayout( + header: .init(isRequired: true, title: "내 초대 코드", limitCount: nil, textCount: nil), + footer: .init(footerText: store.view_textFieldFooterText, status: store.view_invitationCodeStatus) + ) + .focused($focusedField) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + } + } +} diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputFeature.swift index 6cb3b43..3538d8f 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputFeature.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputFeature.swift @@ -6,7 +6,6 @@ // Copyright © 2025 yapp25thTeamTnT. All rights reserved. // -import UIKit import ComposableArchitecture import Domain @@ -56,7 +55,7 @@ public struct TraineePrecautionInputFeature { } } - @Dependency(\.userUseCase) private var userUseCase + @Dependency(\.userUseCase) private var userUseCase: UserUseCase public enum Action: Sendable, ViewAction { /// 뷰에서 발생한 액션을 처리합니다. diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputView.swift index 2e15312..cc9355a 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineePrecautionInput/TraineePrecautionInputView.swift @@ -58,6 +58,7 @@ public struct TraineePrecautionInputView: View { } } + // MARK: - Sections @ViewBuilder private func Header() -> some View { VStack(spacing: 12) { diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeProfileCompletion/TraineeProfileCompletionFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeProfileCompletion/TraineeProfileCompletionFeature.swift new file mode 100644 index 0000000..5fe7f58 --- /dev/null +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeProfileCompletion/TraineeProfileCompletionFeature.swift @@ -0,0 +1,86 @@ +// +// TraineeProfileCompletionFeature.swift +// Presentation +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import Foundation +import ComposableArchitecture + +import Domain + +/// 회원가입 완료 후 프로필 정보를 표시하는 리듀서 +@Reducer +public struct TraineeProfileCompletionFeature { + + @ObservableState + public struct State: Equatable { + // MARK: Data related state + /// 현재 사용자 유저 타입 (트레이너/트레이니) + var userType: UserType + /// 현재 사용자 이름 + var userName: String + /// 등록한 프로필 이미지 데이터 + var profileImage: Data? + + // MARK: UI related state + /// 상대방 유저 타입 (사용자가 트레이너면 트레이니, 트레이니면 트레이너) + var view_opponentUserType: UserType { + return userType == .trainer ? .trainee : .trainer + } + + /// `TraineeProfileCompletionFeature.State`의 생성자 + /// - Parameters: + /// - userType: 현재 선택된 유저 타입 (기본값: `.trainee`) + /// - userName: 입력된 사용자 이름 (기본값: `""`) + /// - profileImage: 등록한 프로필 이미지 데이터 (기본값: `nil`) + public init( + userType: UserType = .trainee, + userName: String = "", + profileImage: Data? = nil + ) { + self.userType = userType + self.userName = userName + self.profileImage = profileImage + } + } + + public enum Action: Sendable, ViewAction { + /// 뷰에서 발생한 액션을 처리합니다. + case view(View) + /// 네비게이션 여부 설정 + case setNavigating + + @CasePathable + public enum View: Sendable, BindableAction { + /// 바인딩할 액션을 처리 + case binding(BindingAction) + /// "다음으로" 버튼이 눌렸을 때 + case tapNextButton + } + } + + public init() {} + + public var body: some ReducerOf { + BindingReducer(action: \.view) + + Reduce { state, action in + switch action { + case .view(let action): + switch action { + case .binding: + return .none + + case .tapNextButton: + return .send(.setNavigating) + } + + case .setNavigating: + return .none + } + } + } +} diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeProfileCompletion/TraineeProfileCompletionView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeProfileCompletion/TraineeProfileCompletionView.swift new file mode 100644 index 0000000..fe9c0a1 --- /dev/null +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeProfileCompletion/TraineeProfileCompletionView.swift @@ -0,0 +1,75 @@ +// +// TraineeInfoInputCompletionView.swift +// Presentation +// +// Created by 박민서 on 1/26/25. +// Copyright © 2025 yapp25thTeamTnT. All rights reserved. +// + +import SwiftUI +import ComposableArchitecture + +import Domain +import DesignSystem + +/// 회원가입 완료 후 프로필 정보를 표시하는 화면 +/// - 사용자 타입 및 이름을 표시 +/// - 상대 유저 타입을 안내하는 메시지 제공 +/// - "시작하기" 버튼을 통해 다음 화면으로 이동 +@ViewAction(for: TraineeProfileCompletionFeature.self) +public struct TraineeProfileCompletionView: View { + + @Bindable public var store: StoreOf + + /// `ProfileCompletion` 생성자 + /// - Parameter store: `TraineeProfileCompletionFeature`와 연결된 Store + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + VStack(spacing: 28) { + Header() + ImageSection() + } + .padding(.top, 100) + + Spacer() + } + .navigationBarBackButtonHidden() + .keyboardDismissOnTap() + .safeAreaInset(edge: .bottom) { + TBottomButton( + title: "시작하기", + isEnable: true + ) { + send(.tapNextButton) + } + } + } + + // MARK: - Sections + @ViewBuilder + private func Header() -> some View { + VStack(spacing: 10) { + Text("만나서 반가워요\n\(store.userName) \(store.userType.koreanName)님!") + .typographyStyle(.heading1, with: .neutral950) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + + Text("\(store.view_opponentUserType.koreanName)와 함께\n케미를 터뜨려보세요! 🧨") + .typographyStyle(.body1Medium, with: .neutral500) + .multilineTextAlignment(.center) + } + } + + @ViewBuilder + private func ImageSection() -> some View { + Image(.imgDefaultTraineeImage) + .resizable() + .frame(width: 200, height: 200) + .clipShape(Circle()) + } +} diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeFeature.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeFeature.swift index c642c44..6012ce0 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeFeature.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeFeature.swift @@ -6,11 +6,9 @@ // Copyright © 2025 yapp25thTeamTnT. All rights reserved. // -import UIKit import ComposableArchitecture import Domain -import DesignSystem /// 트레이니의 PT 목적 선택을 관리하는 리듀서 /// - 사용자가 다중 선택 가능한 PT 목적을 선택하고 검증하는 기능 포함 diff --git a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeView.swift b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeView.swift index 0b0d7f8..cbfa505 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/Trainee/TraineeTrainingPurpose/TraineeTrainingPurposeView.swift @@ -54,6 +54,7 @@ public struct TraineeTrainingPurposeView: View { } } + // MARK: - Sections @ViewBuilder private func Header() -> some View { VStack(spacing: 12) { diff --git a/TnT/Projects/Presentation/Sources/Onboarding/UserTypeSelection/UserTypeSelectionView.swift b/TnT/Projects/Presentation/Sources/Onboarding/UserTypeSelection/UserTypeSelectionView.swift index 1d7cd2a..b06885a 100644 --- a/TnT/Projects/Presentation/Sources/Onboarding/UserTypeSelection/UserTypeSelectionView.swift +++ b/TnT/Projects/Presentation/Sources/Onboarding/UserTypeSelection/UserTypeSelectionView.swift @@ -26,10 +26,7 @@ public struct UserTypeSelectionView: View { public var body: some View { NavigationStack { - VStack { - - Spacer(minLength: 60) - + VStack(spacing: 0) { VStack(spacing: 48) { Header() @@ -37,6 +34,7 @@ public struct UserTypeSelectionView: View { ButtonSection() } + .padding(.top, 60) Spacer() } @@ -60,6 +58,7 @@ public struct UserTypeSelectionView: View { } } + // MARK: - Sections @ViewBuilder private func Header() -> some View { VStack(alignment: .leading, spacing: 12) { diff --git a/TnT/Projects/Presentation/Sources/Utility/KeyboardDismiss.swift b/TnT/Projects/Presentation/Sources/Utility/KeyboardDismiss.swift index f52a992..ea070de 100644 --- a/TnT/Projects/Presentation/Sources/Utility/KeyboardDismiss.swift +++ b/TnT/Projects/Presentation/Sources/Utility/KeyboardDismiss.swift @@ -13,21 +13,27 @@ struct KeyboardDismissModifier: ViewModifier { var dismissOnDrag: Bool = true func body(content: Content) -> some View { - content - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.clear) - .onTapGesture { - dismissKeyboard() - } - .simultaneousGesture( - dismissOnDrag ? DragGesture().onChanged { _ in dismissKeyboard() } : nil - ) + GeometryReader { proxy in + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + dismissKeyboard() + } + .gesture( + dismissOnDrag ? DragGesture().onChanged { _ in dismissKeyboard() } : nil + ) + .overlay(content) + } } - - /// Modifier 내부에서 직접 키보드 내리는 함수 + + /// 키보드를 내리는 함수 private func dismissKeyboard() { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } - windowScene.windows.forEach { $0.endEditing(true) } + guard let window = UIApplication.shared + .connectedScenes + .compactMap({ ($0 as? UIWindowScene)?.keyWindow }) + .first else { return } + + window.endEditing(true) } } diff --git a/TnT/Tuist/Config/Info.plist b/TnT/Tuist/Config/Info.plist index db58c8f..03f27bb 100644 --- a/TnT/Tuist/Config/Info.plist +++ b/TnT/Tuist/Config/Info.plist @@ -64,5 +64,9 @@ UIUserInterfaceStyle Light + API_BASE_URL_DEV + $(API_BASE_URL_DEV) + KAKAO_NATIVE_APP_KEY + $(KAKAO_NATIVE_APP_KEY) diff --git a/TnT/Tuist/ProjectDescriptionHelpers/Dependency/DependencyInformation.swift b/TnT/Tuist/ProjectDescriptionHelpers/Dependency/DependencyInformation.swift index f7d4fc0..7da5ee1 100644 --- a/TnT/Tuist/ProjectDescriptionHelpers/Dependency/DependencyInformation.swift +++ b/TnT/Tuist/ProjectDescriptionHelpers/Dependency/DependencyInformation.swift @@ -9,10 +9,11 @@ let dependencyInfo: [DependencyInformation: [DependencyInformation]] = [ .TnTApp: [.Presentation, .Data], - .Presentation: [.DesignSystem, .Domain, .ComposableArchitecture], + .Presentation: [.DIContainer, .DesignSystem, .Domain, .ComposableArchitecture], .Domain: [.SwiftDepedencies], .Data: [.Domain, .KakaoSDKUser, .SwiftDepedencies], .DesignSystem: [.Lottie], + .DIContainer: [.Domain, .Data] ] public enum DependencyInformation: String, CaseIterable, Sendable { @@ -20,6 +21,7 @@ public enum DependencyInformation: String, CaseIterable, Sendable { case Presentation = "Presentation" case Domain = "Domain" case Data = "Data" + case DIContainer = "DIContainer" case DesignSystem = "DesignSystem" case Lottie = "Lottie" case ComposableArchitecture = "ComposableArchitecture" diff --git a/TnT/Workspace.swift b/TnT/Workspace.swift index b0184e3..5010b0e 100644 --- a/TnT/Workspace.swift +++ b/TnT/Workspace.swift @@ -14,7 +14,8 @@ let workspace = Workspace( "Projects/Presentation", "Projects/DesignSystem", "Projects/Domain", - "Projects/Data" + "Projects/Data", + "Projects/DIContainer" ], generationOptions: .options( autogeneratedWorkspaceSchemes: .enabled()