diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index 3fc72eb6..8b5b8b6d 100644 --- a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift +++ b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift @@ -25,7 +25,7 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { let networkService = DIContainer.shared.resolve(type: NetworkServiceProtocol.self), let keychainStorage = DIContainer.shared.resolve(type: KeychainStorageProtocol.self), let userDefaultsStorage = DIContainer.shared.resolve(type: UserDefaultsStorageProtocol.self) - else { return } + else { fatalError("networkService, keychainStorage, userDefaultsStorage 의존성이 등록되지 않았습니다.") } DIContainer.shared.register(type: AuthRepositoryProtocol.self) { _ in return AuthRepository( @@ -33,5 +33,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { keychainStorage: keychainStorage, userDefaultsStorage: userDefaultsStorage) } + + DIContainer.shared.register(type: OnboardingRepositoryProtocol.self) { _ in + return OnboardingRepository(networkService: networkService, keychainStorage: keychainStorage) + } } } diff --git a/Projects/DataSource/Sources/Common/Error/AuthError.swift b/Projects/DataSource/Sources/Common/Error/AuthError.swift index 33c82da0..babca118 100644 --- a/Projects/DataSource/Sources/Common/Error/AuthError.swift +++ b/Projects/DataSource/Sources/Common/Error/AuthError.swift @@ -13,6 +13,7 @@ enum AuthError: Error, CustomStringConvertible { case nicknameSaveFailed case nicknameLoadFailed case nicknameRemoveFailed + case invalidUserData case unknown(Error) public var description: String { @@ -31,6 +32,8 @@ enum AuthError: Error, CustomStringConvertible { return "닉네임 불러오기에 실패했습니다." case .nicknameRemoveFailed: return "닉네임 삭제에 실패했습니다." + case .invalidUserData: + return "서버 응답에 사용자 정보가 포함되어 있지 않습니다." case .unknown(let error): return "알 수 없는 에러가 발생했습니다. \(error.localizedDescription)" } diff --git a/Projects/DataSource/Sources/DTO/EmptyResponseDTO.swift b/Projects/DataSource/Sources/DTO/EmptyResponseDTO.swift new file mode 100644 index 00000000..61216c5b --- /dev/null +++ b/Projects/DataSource/Sources/DTO/EmptyResponseDTO.swift @@ -0,0 +1,8 @@ +// +// EmptyResponseDTO.swift +// DataSource +// +// Created by 최정인 on 7/15/25. +// + +struct EmptyResponseDTO: Decodable {} diff --git a/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift b/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift index 7b16f7b1..ffdbcb76 100644 --- a/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift +++ b/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift @@ -7,9 +7,7 @@ import Domain -typealias LoginResponseDTO = BaseResponseDTO - -struct LoginResponse: Decodable { +struct LoginResponseDTO: Decodable { let accessToken: String let refreshToken: String let userState: String @@ -21,7 +19,7 @@ struct LoginResponse: Decodable { } } -extension LoginResponse { +extension LoginResponseDTO { func toUserEntity() -> UserEntity { return UserEntity( accessToken: accessToken, diff --git a/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift b/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift new file mode 100644 index 00000000..51a59a6e --- /dev/null +++ b/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift @@ -0,0 +1,43 @@ +// +// RecommendedRoutineDTO.swift +// DataSource +// +// Created by 최정인 on 7/15/25. +// + +import Domain + +struct RecommendedRoutineListResponseDTO: Decodable { + let recommendedRoutines: [RecommendedRoutineDTO] +} + +struct RecommendedRoutineDTO: Decodable { + let id: Int + let routineName: String + let routineDescription: String + let subRoutines: [SubRoutine] + + enum CodingKeys: String, CodingKey { + case id = "recommendedRoutineId" + case routineName = "recommendedRoutineName" + case routineDescription + case subRoutines = "recommendedSubRoutines" + } + + func toRecommendedRoutineEntity() -> RecommendedRoutineEntity { + return RecommendedRoutineEntity( + id: id, + title: routineName, + description: routineDescription) + } +} + +struct SubRoutine: Decodable { + let id: Int + let routineName: String + + enum CodingKeys: String, CodingKey { + case id = "recommendedSubRoutineId" + case routineName = "recommendedSubRoutineName" + } +} diff --git a/Projects/DataSource/Sources/Endpoints/AuthEndpoint.swift b/Projects/DataSource/Sources/Endpoint/AuthEndpoint.swift similarity index 100% rename from Projects/DataSource/Sources/Endpoints/AuthEndpoint.swift rename to Projects/DataSource/Sources/Endpoint/AuthEndpoint.swift diff --git a/Projects/DataSource/Sources/Endpoint/OnboardingEndpoint.swift b/Projects/DataSource/Sources/Endpoint/OnboardingEndpoint.swift new file mode 100644 index 00000000..e0be0991 --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/OnboardingEndpoint.swift @@ -0,0 +1,59 @@ +// +// OnboardingEndpoint.swift +// DataSource +// +// Created by 최정인 on 7/15/25. +// + +import Foundation + +enum OnboardingEndpoint { + case registerOnboarding(accessToken: String, choices: [String: String]) + case registerRecommendedRoutine(accessToken: String, selectedRoutines: [Int]) +} + +extension OnboardingEndpoint: Endpoint { + var baseURL: String { + return AppProperties.baseURL + "/api/v1/onboardings" + } + + var path: String { + switch self { + case .registerOnboarding: baseURL + case .registerRecommendedRoutine: baseURL + "/routines" + } + } + + var method: HTTPMethod { + return .post + } + + var headers: [String : String] { + var headers: [String: String] = [ + "Content-Type": "application/json", + "accept": "*/*" + ] + + switch self { + case .registerOnboarding(let accessToken, _): + headers["Authorization"] = "Bearer \(accessToken)" + case .registerRecommendedRoutine(let accessToken, _): + headers["Authorization"] = "Bearer \(accessToken)" + } + + return headers + } + + var queryParameters: [String : String] { + return [:] + } + + var bodyParameters: [String : Any] { + switch self { + case .registerOnboarding(_, let choices): + return choices + case .registerRecommendedRoutine(_, let selectedRoutines): + return ["recommendedRoutineIds": selectedRoutines] + } + } +} diff --git a/Projects/DataSource/Sources/Protocols/KeychainStorageProtocol.swift b/Projects/DataSource/Sources/Protocol/KeychainStorageProtocol.swift similarity index 100% rename from Projects/DataSource/Sources/Protocols/KeychainStorageProtocol.swift rename to Projects/DataSource/Sources/Protocol/KeychainStorageProtocol.swift diff --git a/Projects/DataSource/Sources/Protocols/NetworkServiceProtocol.swift b/Projects/DataSource/Sources/Protocol/NetworkServiceProtocol.swift similarity index 96% rename from Projects/DataSource/Sources/Protocols/NetworkServiceProtocol.swift rename to Projects/DataSource/Sources/Protocol/NetworkServiceProtocol.swift index 8776cad2..b4f07c58 100644 --- a/Projects/DataSource/Sources/Protocols/NetworkServiceProtocol.swift +++ b/Projects/DataSource/Sources/Protocol/NetworkServiceProtocol.swift @@ -15,5 +15,5 @@ public protocol NetworkServiceProtocol { /// - endpoint: 요청을 보낼 API Endpoint 정보 /// - type: 디코딩할 Response DTO 타입 /// - Returns: 응답 데이터를 디코딩한 객체 - func request(endpoint: Endpoint, type: T.Type) async throws -> T + func request(endpoint: Endpoint, type: T.Type) async throws -> T? } diff --git a/Projects/DataSource/Sources/Protocols/UserDefaultsStorageProtocol.swift b/Projects/DataSource/Sources/Protocol/UserDefaultsStorageProtocol.swift similarity index 100% rename from Projects/DataSource/Sources/Protocols/UserDefaultsStorageProtocol.swift rename to Projects/DataSource/Sources/Protocol/UserDefaultsStorageProtocol.swift diff --git a/Projects/DataSource/Sources/Repositories/AuthRepository.swift b/Projects/DataSource/Sources/Repository/AuthRepository.swift similarity index 77% rename from Projects/DataSource/Sources/Repositories/AuthRepository.swift rename to Projects/DataSource/Sources/Repository/AuthRepository.swift index 721f9c62..ea88542c 100644 --- a/Projects/DataSource/Sources/Repositories/AuthRepository.swift +++ b/Projects/DataSource/Sources/Repository/AuthRepository.swift @@ -11,12 +11,12 @@ import Shared import KakaoSDKUser import KakaoSDKAuth -public final class AuthRepository: AuthRepositoryProtocol { +final class AuthRepository: AuthRepositoryProtocol { private let networkService: NetworkServiceProtocol private let keychainStorage: KeychainStorageProtocol private let userDefaultsStorage: UserDefaultsStorageProtocol - public init( + init( networkService: NetworkServiceProtocol, keychainStorage: KeychainStorageProtocol, userDefaultsStorage: UserDefaultsStorageProtocol @@ -26,12 +26,16 @@ public final class AuthRepository: AuthRepositoryProtocol { self.userDefaultsStorage = userDefaultsStorage } - public func kakaoLogin() async throws { + func kakaoLogin() async throws -> UserEntity { let accessToken = try await fetchKakaoToken() - try await requestServerLogin(socialType: .kakao, nickname: nil, token: accessToken) + let user = try await requestServerLogin( + socialType: .kakao, + nickname: nil, + token: accessToken) + return user } - public func appleLogin(nickname: String?, authToken: String) async throws { + func appleLogin(nickname: String?, authToken: String) async throws -> UserEntity { var savedNickname: String = "" if let nickname { try saveNickname(nickname: nickname) @@ -39,39 +43,43 @@ public final class AuthRepository: AuthRepositoryProtocol { } else { savedNickname = try loadNickname() } - try await requestServerLogin(socialType: .apple, nickname: savedNickname, token: authToken) + let user = try await requestServerLogin( + socialType: .apple, + nickname: savedNickname, + token: authToken) + return user } - public func submitAgreement(agreements: [TermsType : Bool]) async throws { + func submitAgreement(agreements: [TermsType : Bool]) async throws { let accessToken = try loadToken(tokenType: .accessToken) let endpoint = AuthEndpoint.agreements(accessToken: accessToken, agreements: agreements) - let response = try await networkService.request(endpoint: endpoint, type: BaseResponseDTO.self) - BitnagilLogger.log(logType: .debug, message: "\(response)") + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) } - public func logout() async throws { + func logout() async throws { let accessToken = try loadToken(tokenType: .accessToken) let endpoint = AuthEndpoint.logout(accessToken: accessToken) - let response = try await networkService.request(endpoint: endpoint, type: BaseResponseDTO.self) + _ = try await networkService.request(endpoint: endpoint, type: String.self) try removeToken() - BitnagilLogger.log(logType: .debug, message: "\(response.message)") } - public func withdraw() async throws { + func withdraw() async throws { let accessToken = try loadToken(tokenType: .accessToken) let endpoint = AuthEndpoint.withdraw(accessToken: accessToken) - let response = try await networkService.request(endpoint: endpoint, type: BaseResponseDTO.self) + _ = try await networkService.request(endpoint: endpoint, type: String.self) try removeToken() try removeNickname() - BitnagilLogger.log(logType: .debug, message: "\(response.message)") } - public func reissueToken() async throws { + func reissueToken() async throws { let refreshToken = try loadToken(tokenType: .refreshToken) let endpoint = AuthEndpoint.reissue(refreshToken: refreshToken) - let userResponse = try await networkService.request(endpoint: endpoint, type: LoginResponseDTO.self) + + guard let userResponse = try await networkService.request(endpoint: endpoint, type: LoginResponseDTO.self) + else { return } + let userEntity = userResponse.toUserEntity() + guard - let userEntity = userResponse.data?.toUserEntity(), saveToken(tokenType: .accessToken, token: userEntity.accessToken), saveToken(tokenType: .refreshToken, token: userEntity.refreshToken) else { throw AuthError.tokenSaveFailed } @@ -80,7 +88,7 @@ public final class AuthRepository: AuthRepositoryProtocol { BitnagilLogger.log(logType: .debug, message: "AccessToken Saved: \(userEntity.accessToken)") BitnagilLogger.log(logType: .debug, message: "RefreshToken Saved: \(userEntity.refreshToken)") } - + private func fetchKakaoToken() async throws -> String { try await withCheckedThrowingContinuation { continuation in let resultHandler: (OAuthToken?, Error?) -> Void = { oauthToken, error in @@ -107,15 +115,17 @@ public final class AuthRepository: AuthRepositoryProtocol { socialType: SocialLoginType, nickname: String?, token: String - ) async throws { + ) async throws -> UserEntity { let endpoint = AuthEndpoint.login( socialLoginType: socialType, nickname: nickname, token: token) - let userResponse = try await networkService.request(endpoint: endpoint, type: LoginResponseDTO.self) + guard let userResponse = try await networkService.request(endpoint: endpoint, type: LoginResponseDTO.self) + else { throw AuthError.invalidUserData } + + let userEntity = userResponse.toUserEntity() guard - let userEntity = userResponse.data?.toUserEntity(), saveToken(tokenType: .accessToken, token: userEntity.accessToken), saveToken(tokenType: .refreshToken, token: userEntity.refreshToken) else { throw AuthError.tokenSaveFailed } @@ -123,6 +133,8 @@ public final class AuthRepository: AuthRepositoryProtocol { BitnagilLogger.log(logType: .debug, message: "User Logined: \(userEntity.userState)") BitnagilLogger.log(logType: .debug, message: "AccessToken Saved: \(userEntity.accessToken)") BitnagilLogger.log(logType: .debug, message: "RefreshToken Saved: \(userEntity.refreshToken)") + + return userEntity } private func saveToken(tokenType: TokenType, token: String) -> Bool { diff --git a/Projects/DataSource/Sources/Repository/OnboardingRepository.swift b/Projects/DataSource/Sources/Repository/OnboardingRepository.swift new file mode 100644 index 00000000..1f1ebe03 --- /dev/null +++ b/Projects/DataSource/Sources/Repository/OnboardingRepository.swift @@ -0,0 +1,42 @@ +// +// OnboardingRepository.swift +// DataSource +// +// Created by 최정인 on 7/15/25. +// + +import Domain + +final class OnboardingRepository: OnboardingRepositoryProtocol { + private let networkService: NetworkServiceProtocol + private let keychainStorage: KeychainStorageProtocol + + init(networkService: NetworkServiceProtocol, keychainStorage: KeychainStorageProtocol) { + self.networkService = networkService + self.keychainStorage = keychainStorage + } + + func registerOnboarding(onboardingChoices: [String : String]) async throws -> [RecommendedRoutineEntity] { + let accessToken = try loadToken(tokenType: .accessToken) + let endpoint = OnboardingEndpoint.registerOnboarding(accessToken: accessToken, choices: onboardingChoices) + + guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self) + else { return [] } + + let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() }) + return recommendedRoutineEntity + } + + func registerRecommendedRoutines(selectedRoutines: [Int]) async throws { + let accessToken = try loadToken(tokenType: .accessToken) + let endpoint = OnboardingEndpoint.registerRecommendedRoutine(accessToken: accessToken, selectedRoutines: selectedRoutines) + _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) + } + + private func loadToken(tokenType: TokenType) throws -> String { + guard let token = keychainStorage.load(forKey: tokenType.rawValue) else { + throw AuthError.tokenLoadFailed + } + return token + } +} diff --git a/Projects/Domain/Sources/DomainDependencyAssembler.swift b/Projects/Domain/Sources/DomainDependencyAssembler.swift index 97eeccf2..dffbecbe 100644 --- a/Projects/Domain/Sources/DomainDependencyAssembler.swift +++ b/Projects/Domain/Sources/DomainDependencyAssembler.swift @@ -18,9 +18,8 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { public func assemble() { preAssembler.assemble() - guard let authRepository = DIContainer.shared.resolve(type: AuthRepositoryProtocol.self) else { - return - } + guard let authRepository = DIContainer.shared.resolve(type: AuthRepositoryProtocol.self) + else { fatalError("authRepository 의존성이 등록되지 않았습니다.") } DIContainer.shared.register(type: LoginUseCaseProtocol.self) { _ in return LoginUseCase(authRepository: authRepository) @@ -33,5 +32,12 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: WithdrawUseCaseProtocol.self) { _ in return WithdrawUseCase(authRepository: authRepository) } + + DIContainer.shared.register(type: OnboardingUseCaseProtocol.self) { container in + guard let onboardingRepository = container.resolve(type: OnboardingRepositoryProtocol.self) + else { fatalError("onboardingRepository 의존성이 등록되지 않았습니다.") } + + return OnboardingUseCase(onboardingRepository: onboardingRepository) + } } } diff --git a/Projects/Domain/Sources/Entity/Enum/OnboardingChoiceType.swift b/Projects/Domain/Sources/Entity/Enum/OnboardingChoiceType.swift new file mode 100644 index 00000000..ca6f3185 --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/OnboardingChoiceType.swift @@ -0,0 +1,73 @@ +// +// OnboardingChoiceType.swift +// Domain +// +// Created by 최정인 on 7/15/25. +// + +public enum OnboardingChoiceType: CaseIterable { + case morningTime + case eveningTime + case allTime + + case never + case rarely + case sometimes + case often + + case stability + case connection + case growth + case vitality + + case once + case twoToThree + case fourOrMore + case notSure + + public var onboardingType: OnboardingType { + switch self { + case .morningTime: .time + case .eveningTime: .time + case .allTime: .time + + case .never: .frequency + case .rarely: .frequency + case .sometimes: .frequency + case .often: .frequency + + case .stability: .feeling + case .connection: .feeling + case .growth: .feeling + case .vitality: .feeling + + case .once: .outdoor + case .twoToThree: .outdoor + case .fourOrMore: .outdoor + case .notSure: .outdoor + } + } + + var value: String { + switch self { + case .morningTime: "MORNING" + case .eveningTime: "EVENING" + case .allTime: "NOTHING" + + case .never: "ZERO_PER_WEEK" + case .rarely: "ONE_TO_TWO_PER_WEEK" + case .sometimes: "THREE_TO_FOUR_PER_WEEK" + case .often: "MORE_THAN_FIVE_PER_WEEK" + + case .stability: "STABILITY" + case .connection: "CONNECTEDNESS" + case .growth: "GROWTH" + case .vitality: "VITALITY" + + case .once: "ONE_TO_TWO_PER_WEEK" + case .twoToThree: "THREE_TO_FOUR_PER_WEEK" + case .fourOrMore: "MORE_THAN_FIVE_PER_WEEK" + case .notSure: "UNKNOWN" + } + } +} diff --git a/Projects/Domain/Sources/Entity/Enum/OnboardingType.swift b/Projects/Domain/Sources/Entity/Enum/OnboardingType.swift new file mode 100644 index 00000000..e2560ef6 --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/OnboardingType.swift @@ -0,0 +1,22 @@ +// +// OnboardingType.swift +// Domain +// +// Created by 최정인 on 7/15/25. +// + +public enum OnboardingType: CaseIterable { + case time + case frequency + case feeling + case outdoor + + var key: String { + switch self { + case .time: "timeSlot" + case .frequency: "realOutingFrequency" + case .feeling: "emotionType" + case .outdoor: "targetOutingFrequency" + } + } +} diff --git a/Projects/Domain/Sources/Entities/Enum/TermsType.swift b/Projects/Domain/Sources/Entity/Enum/TermsType.swift similarity index 100% rename from Projects/Domain/Sources/Entities/Enum/TermsType.swift rename to Projects/Domain/Sources/Entity/Enum/TermsType.swift diff --git a/Projects/Domain/Sources/Entities/Enum/UserState.swift b/Projects/Domain/Sources/Entity/Enum/UserState.swift similarity index 100% rename from Projects/Domain/Sources/Entities/Enum/UserState.swift rename to Projects/Domain/Sources/Entity/Enum/UserState.swift diff --git a/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift b/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift new file mode 100644 index 00000000..db04a334 --- /dev/null +++ b/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift @@ -0,0 +1,22 @@ +// +// RecommendedRoutineEntity.swift +// Domain +// +// Created by 최정인 on 7/15/25. +// + +public struct RecommendedRoutineEntity { + public let id: Int + public let title: String + public let description: String + + public init( + id: Int, + title: String, + description: String + ) { + self.id = id + self.title = title + self.description = description + } +} diff --git a/Projects/Domain/Sources/Entities/UserEntity.swift b/Projects/Domain/Sources/Entity/UserEntity.swift similarity index 100% rename from Projects/Domain/Sources/Entities/UserEntity.swift rename to Projects/Domain/Sources/Entity/UserEntity.swift diff --git a/Projects/Domain/Sources/Protocols/Repository/AuthRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/AuthRepositoryProtocol.swift similarity index 82% rename from Projects/Domain/Sources/Protocols/Repository/AuthRepositoryProtocol.swift rename to Projects/Domain/Sources/Protocol/Repository/AuthRepositoryProtocol.swift index f83abbea..f0eaba41 100644 --- a/Projects/Domain/Sources/Protocols/Repository/AuthRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Protocol/Repository/AuthRepositoryProtocol.swift @@ -7,13 +7,15 @@ public protocol AuthRepositoryProtocol { /// 카카오 소셜 로그인을 진행합니다. - func kakaoLogin() async throws + /// - Returns: 로그인에 성공한 사용자의 정보 + func kakaoLogin() async throws -> UserEntity /// Apple 소셜 로그인을 진행합니다. /// - Parameters: /// - nickname: Apple 계정의 이름입니다. 첫 로그인 시에만 전달되며 이후에는 UserDefaults에 저장된 값을 사용합니다. /// - authToken: Apple로부터 발급받은 authorization token 값입니다. - func appleLogin(nickname: String?, authToken: String) async throws + /// - Returns: 로그인에 성공한 사용자의 정보 + func appleLogin(nickname: String?, authToken: String) async throws -> UserEntity /// 동의한 약관들에 대한 정보를 보냅니다. func submitAgreement(agreements: [TermsType: Bool]) async throws diff --git a/Projects/Domain/Sources/Protocol/Repository/OnboardingRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/OnboardingRepositoryProtocol.swift new file mode 100644 index 00000000..b0723e80 --- /dev/null +++ b/Projects/Domain/Sources/Protocol/Repository/OnboardingRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// OnboardingRepositoryProtocol.swift +// Domain +// +// Created by 최정인 on 7/15/25. +// + +public protocol OnboardingRepositoryProtocol { + /// 선택한 온보딩 결과를 저장하고, 추천 루틴을 받습니다. + /// - Parameter onboardingChoices: 선택한 온보딩 항목 Dictionary + /// - Returns: 온보딩 결과를 바탕으로 받은 추천루틴 목록 + func registerOnboarding(onboardingChoices: [String: String]) async throws -> [RecommendedRoutineEntity] + + /// 선택한 추천 루틴을 등록합니다. + /// - Parameter selectedRoutines: 선택한 추천 루틴 ID 목록 + func registerRecommendedRoutines(selectedRoutines: [Int]) async throws +} diff --git a/Projects/Domain/Sources/Protocols/UseCase/LoginUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/LoginUseCaseProtocol.swift similarity index 92% rename from Projects/Domain/Sources/Protocols/UseCase/LoginUseCaseProtocol.swift rename to Projects/Domain/Sources/Protocol/UseCase/LoginUseCaseProtocol.swift index e5b39531..a73824f2 100644 --- a/Projects/Domain/Sources/Protocols/UseCase/LoginUseCaseProtocol.swift +++ b/Projects/Domain/Sources/Protocol/UseCase/LoginUseCaseProtocol.swift @@ -7,13 +7,13 @@ public protocol LoginUseCaseProtocol { /// 카카오 소셜 로그인을 진행합니다. - func kakaoLogin() async throws + func kakaoLogin() async throws -> UserState /// Apple 소셜 로그인을 진행합니다. /// - Parameters: /// - nickname: Apple 계정의 이름입니다. 첫 로그인 시에만 전달되며 이후에는 UserDefaults에 저장된 값을 사용합니다. /// - authToken: Apple로부터 발급받은 authorization token 값입니다. - func appleLogin(nickname: String?, authToken: String) async throws + func appleLogin(nickname: String?, authToken: String) async throws -> UserState /// 동의한 약관들에 대한 정보를 보냅니다. /// - Parameter agreements: 각 약관(TermsType)별 동의 여부를 나타내는 딕셔너리 값입니다. diff --git a/Projects/Domain/Sources/Protocols/UseCase/LogoutUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/LogoutUseCaseProtocol.swift similarity index 100% rename from Projects/Domain/Sources/Protocols/UseCase/LogoutUseCaseProtocol.swift rename to Projects/Domain/Sources/Protocol/UseCase/LogoutUseCaseProtocol.swift diff --git a/Projects/Domain/Sources/Protocol/UseCase/OnboardingUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/OnboardingUseCaseProtocol.swift new file mode 100644 index 00000000..7019a28e --- /dev/null +++ b/Projects/Domain/Sources/Protocol/UseCase/OnboardingUseCaseProtocol.swift @@ -0,0 +1,17 @@ +// +// OnboardingUseCaseProtocol.swift +// Domain +// +// Created by 최정인 on 7/15/25. +// + +public protocol OnboardingUseCaseProtocol { + /// 선택한 온보딩 결과를 저장하고, 추천 루틴을 받습니다. + /// - Parameter onboardingChoices: 선택한 온보딩 항목 + /// - Returns: 온보딩 결과를 바탕으로 받은 추천루틴 목록 + func registerOnboarding(onboardingChoices: [OnboardingChoiceType]) async throws -> [RecommendedRoutineEntity] + + /// 선택한 추천 루틴을 등록합니다. + /// - Parameter selectedRoutines: 선택한 추천 루틴 ID 목록 + func registerRecommendedRoutines(selectedRoutines: [Int]) async throws +} diff --git a/Projects/Domain/Sources/Protocols/UseCase/WithdrawUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/WithdrawUseCaseProtocol.swift similarity index 100% rename from Projects/Domain/Sources/Protocols/UseCase/WithdrawUseCaseProtocol.swift rename to Projects/Domain/Sources/Protocol/UseCase/WithdrawUseCaseProtocol.swift diff --git a/Projects/Domain/Sources/UseCases/Auth/LoginUseCase.swift b/Projects/Domain/Sources/UseCase/Auth/LoginUseCase.swift similarity index 65% rename from Projects/Domain/Sources/UseCases/Auth/LoginUseCase.swift rename to Projects/Domain/Sources/UseCase/Auth/LoginUseCase.swift index 362fe6c5..578351c5 100644 --- a/Projects/Domain/Sources/UseCases/Auth/LoginUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Auth/LoginUseCase.swift @@ -14,12 +14,14 @@ public final class LoginUseCase: LoginUseCaseProtocol { self.authRepository = authRepository } - public func kakaoLogin() async throws { - try await authRepository.kakaoLogin() + public func kakaoLogin() async throws -> UserState { + let user = try await authRepository.kakaoLogin() + return user.userState } - public func appleLogin(nickname: String?, authToken: String) async throws { - try await authRepository.appleLogin(nickname: nickname, authToken: authToken) + public func appleLogin(nickname: String?, authToken: String) async throws -> UserState { + let user = try await authRepository.appleLogin(nickname: nickname, authToken: authToken) + return user.userState } public func sumbitAgreement(agreements: [TermsType: Bool]) async throws { diff --git a/Projects/Domain/Sources/UseCases/Auth/LogoutUseCase.swift b/Projects/Domain/Sources/UseCase/Auth/LogoutUseCase.swift similarity index 100% rename from Projects/Domain/Sources/UseCases/Auth/LogoutUseCase.swift rename to Projects/Domain/Sources/UseCase/Auth/LogoutUseCase.swift diff --git a/Projects/Domain/Sources/UseCases/Auth/WithdrawUseCase.swift b/Projects/Domain/Sources/UseCase/Auth/WithdrawUseCase.swift similarity index 100% rename from Projects/Domain/Sources/UseCases/Auth/WithdrawUseCase.swift rename to Projects/Domain/Sources/UseCase/Auth/WithdrawUseCase.swift diff --git a/Projects/Domain/Sources/UseCase/Onboarding/OnboardingUseCase.swift b/Projects/Domain/Sources/UseCase/Onboarding/OnboardingUseCase.swift new file mode 100644 index 00000000..0831e320 --- /dev/null +++ b/Projects/Domain/Sources/UseCase/Onboarding/OnboardingUseCase.swift @@ -0,0 +1,37 @@ +// +// OnboardingUseCase.swift +// Domain +// +// Created by 최정인 on 7/15/25. +// + +import Foundation + +public final class OnboardingUseCase: OnboardingUseCaseProtocol { + private let onboardingRepository: OnboardingRepositoryProtocol + + public init(onboardingRepository: OnboardingRepositoryProtocol) { + self.onboardingRepository = onboardingRepository + } + + public func registerOnboarding(onboardingChoices: [OnboardingChoiceType]) async throws -> [RecommendedRoutineEntity] { + let choices = convertToDictionary(onboardingChoices: onboardingChoices) + let recommendedRoutines = try await onboardingRepository.registerOnboarding(onboardingChoices: choices) + return recommendedRoutines + } + + public func registerRecommendedRoutines(selectedRoutines: [Int]) async throws { + try await onboardingRepository.registerRecommendedRoutines(selectedRoutines: selectedRoutines) + } + + private func convertToDictionary(onboardingChoices: [OnboardingChoiceType]) -> [String: String] { + var result: [String: String] = [:] + let onboardingTypes: [OnboardingType] = [.time, .frequency, .feeling, .outdoor] + for type in onboardingTypes { + guard let choice = onboardingChoices.first(where: { $0.onboardingType == type }) + else { break } + result[choice.onboardingType.key] = choice.value + } + return result + } +} diff --git a/Projects/DataSource/Sources/DTO/BaseResponseDTO.swift b/Projects/NetworkService/Sources/BaseResponse.swift similarity index 52% rename from Projects/DataSource/Sources/DTO/BaseResponseDTO.swift rename to Projects/NetworkService/Sources/BaseResponse.swift index 0f123ffb..884f7c9e 100644 --- a/Projects/DataSource/Sources/DTO/BaseResponseDTO.swift +++ b/Projects/NetworkService/Sources/BaseResponse.swift @@ -1,14 +1,12 @@ // -// BaseResponseDTO.swift +// BaseResponse.swift // DataSource // // Created by 최정인 on 6/23/25. // -struct BaseResponseDTO: Decodable { +struct BaseResponse: Decodable { let code: String let data: T? let message: String } - -struct EmptyResponse: Decodable {} diff --git a/Projects/NetworkService/Sources/Extensions/Endpoint+.swift b/Projects/NetworkService/Sources/Extension/Endpoint+.swift similarity index 100% rename from Projects/NetworkService/Sources/Extensions/Endpoint+.swift rename to Projects/NetworkService/Sources/Extension/Endpoint+.swift diff --git a/Projects/NetworkService/Sources/Extensions/URLRequest+.swift b/Projects/NetworkService/Sources/Extension/URLRequest+.swift similarity index 100% rename from Projects/NetworkService/Sources/Extensions/URLRequest+.swift rename to Projects/NetworkService/Sources/Extension/URLRequest+.swift diff --git a/Projects/NetworkService/Sources/Extensions/URLSession+.swift b/Projects/NetworkService/Sources/Extension/URLSession+.swift similarity index 100% rename from Projects/NetworkService/Sources/Extensions/URLSession+.swift rename to Projects/NetworkService/Sources/Extension/URLSession+.swift diff --git a/Projects/NetworkService/Sources/NetworkService.swift b/Projects/NetworkService/Sources/NetworkService.swift index 084f512b..953be80d 100644 --- a/Projects/NetworkService/Sources/NetworkService.swift +++ b/Projects/NetworkService/Sources/NetworkService.swift @@ -17,27 +17,31 @@ public final class NetworkService: NetworkServiceProtocol { self.networkProvider = networkProvider } - public func request(endpoint: Endpoint, type: T.Type) async throws -> T { + public func request(endpoint: Endpoint, type: T.Type) async throws -> T? { let request = try endpoint.makeURLRequest() let (data, response) = try await networkProvider.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw NetworkError.invalidResponse - } + guard let httpResponse = response as? HTTPURLResponse + else { throw NetworkError.invalidResponse } guard 200..<300 ~= httpResponse.statusCode else { BitnagilLogger.log(logType: .error, message: "응답 코드: \(httpResponse.statusCode)") throw NetworkError.invalidStatusCode(statusCode: httpResponse.statusCode) } - guard !data.isEmpty else { - throw NetworkError.emptyData - } + guard !data.isEmpty + else { throw NetworkError.emptyData } - guard let responseDTO = try? decoder.decode(T.self, from: data) else { + do { + let baseResponse = try decoder.decode(BaseResponse.self, from: data) + BitnagilLogger.log(logType: .info, message: "Server Message: \(baseResponse.message)") + + guard let responseDTO = baseResponse.data + else { return nil } + + return responseDTO + } catch { throw NetworkError.decodingError } - - return responseDTO } } diff --git a/Projects/Presentation/Sources/Common/Components/PrimaryButton.swift b/Projects/Presentation/Sources/Common/Component/PrimaryButton.swift similarity index 100% rename from Projects/Presentation/Sources/Common/Components/PrimaryButton.swift rename to Projects/Presentation/Sources/Common/Component/PrimaryButton.swift diff --git a/Projects/Presentation/Sources/Common/DesignSystem/Font/BitnagilFont.swift b/Projects/Presentation/Sources/Common/DesignSystem/Font/BitnagilFont.swift index f153cf36..96bf5a53 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/Font/BitnagilFont.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/Font/BitnagilFont.swift @@ -16,6 +16,21 @@ struct BitnagilFont { self.weight = weight } + init(fontSize: CGFloat, + lineHeight: CGFloat, + letterSpacing: CGFloat = 0, + underline: Bool = false, + weight: FontWeight + ) { + let attributes = FontAttributes( + fontSize: fontSize, + lineHeight: lineHeight, + letterSpacing: letterSpacing, + underline: underline) + self.style = .custom(fontAttribute: attributes) + self.weight = weight + } + var font: UIFont { guard let font = UIFont(name: weight.fontName, size: style.fontAttributes.fontSize) else { return UIFont.systemFont(ofSize: style.fontAttributes.fontSize) diff --git a/Projects/Presentation/Sources/Common/DesignSystem/Font/FontStyle.swift b/Projects/Presentation/Sources/Common/DesignSystem/Font/FontStyle.swift index 20707539..3fa8ce0c 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/Font/FontStyle.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/Font/FontStyle.swift @@ -25,6 +25,8 @@ enum FontStyle { case button1 case button2 + case custom(fontAttribute: FontAttributes) + var fontAttributes: FontAttributes { switch self { case .headline1: FontAttributes(fontSize: 26, lineHeight: 38, letterSpacing: -0.5) @@ -45,6 +47,8 @@ enum FontStyle { case .button1: FontAttributes(fontSize: 16, lineHeight: 24) case .button2: FontAttributes(fontSize: 14, lineHeight: 20) + + case .custom(let fontAttribute): fontAttribute } } } diff --git a/Projects/Presentation/Sources/Common/Extensions/NSAttributedString+.swift b/Projects/Presentation/Sources/Common/Extension/NSAttributedString+.swift similarity index 100% rename from Projects/Presentation/Sources/Common/Extensions/NSAttributedString+.swift rename to Projects/Presentation/Sources/Common/Extension/NSAttributedString+.swift diff --git a/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift b/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift similarity index 96% rename from Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift rename to Projects/Presentation/Sources/Common/Extension/UIViewController+.swift index e15844e2..b38f56cd 100644 --- a/Projects/Presentation/Sources/Common/Extensions/UIViewController+.swift +++ b/Projects/Presentation/Sources/Common/Extension/UIViewController+.swift @@ -62,7 +62,8 @@ extension UIViewController { } @objc private func popTwoViewControllers() { - guard let navigationController = navigationController else { return } + guard let navigationController = navigationController + else { return } let viewControllers = navigationController.viewControllers guard viewControllers.count >= 3 else { diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 6cc4b314..ca239d1b 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -23,14 +23,18 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return HomeViewModel() } - DIContainer.shared.register(type: OnboardingViewModel.self) { _ in - return OnboardingViewModel() - } - DIContainer.shared.register(type: LoginViewModel.self) { container in guard let loginUseCase = container.resolve(type: LoginUseCaseProtocol.self) - else { return } + else { fatalError("loginUseCase 의존성이 등록되지 않았습니다.") } + return LoginViewModel(loginUseCase: loginUseCase) } + + DIContainer.shared.register(type: OnboardingViewModel.self) { container in + guard let onboardingUseCase = container.resolve(type: OnboardingUseCaseProtocol.self) + else { fatalError("onboardingUseCase 의존성이 등록되지 않았습니다.") } + + return OnboardingViewModel(onboardingUseCase: onboardingUseCase) + } } } diff --git a/Projects/Presentation/Sources/Home/HomeViewController.swift b/Projects/Presentation/Sources/Home/HomeViewController.swift index ce1a82d7..dea0fd89 100644 --- a/Projects/Presentation/Sources/Home/HomeViewController.swift +++ b/Projects/Presentation/Sources/Home/HomeViewController.swift @@ -12,12 +12,12 @@ import SnapKit import Then import Shared -public final class HomeViewController: BaseViewController { +final class HomeViewController: BaseViewController { private var cancellables: Set private let label = UILabel() - public override init(viewModel: HomeViewModel) { + override init(viewModel: HomeViewModel) { cancellables = [] super.init(viewModel: viewModel) } @@ -26,7 +26,7 @@ public final class HomeViewController: BaseViewController { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() } diff --git a/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift b/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift index b95403f2..2aaf2c4c 100644 --- a/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift +++ b/Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift @@ -8,16 +8,16 @@ import Combine import Domain -public final class HomeViewModel: ViewModel { - public enum Input { } +final class HomeViewModel: ViewModel { + enum Input { } - public struct Output { } + struct Output { } private(set) var output: Output - public init() { + init() { self.output = Output() } - public func action(input: Input) { } + func action(input: Input) { } } diff --git a/Projects/Presentation/Sources/Login/View/Components/SocialLoginButton.swift b/Projects/Presentation/Sources/Login/View/Component/SocialLoginButton.swift similarity index 100% rename from Projects/Presentation/Sources/Login/View/Components/SocialLoginButton.swift rename to Projects/Presentation/Sources/Login/View/Component/SocialLoginButton.swift diff --git a/Projects/Presentation/Sources/Login/View/Components/TermsAgreementItemView.swift b/Projects/Presentation/Sources/Login/View/Component/TermsAgreementItemView.swift similarity index 100% rename from Projects/Presentation/Sources/Login/View/Components/TermsAgreementItemView.swift rename to Projects/Presentation/Sources/Login/View/Component/TermsAgreementItemView.swift diff --git a/Projects/Presentation/Sources/Login/View/Components/TotalAgreementButton.swift b/Projects/Presentation/Sources/Login/View/Component/TotalAgreementButton.swift similarity index 100% rename from Projects/Presentation/Sources/Login/View/Components/TotalAgreementButton.swift rename to Projects/Presentation/Sources/Login/View/Component/TotalAgreementButton.swift diff --git a/Projects/Presentation/Sources/Login/View/LoginView.swift b/Projects/Presentation/Sources/Login/View/LoginView.swift index ded5500e..36873888 100644 --- a/Projects/Presentation/Sources/Login/View/LoginView.swift +++ b/Projects/Presentation/Sources/Login/View/LoginView.swift @@ -12,7 +12,7 @@ import Shared import SnapKit import Then -public final class LoginView: BaseViewController { +final class LoginView: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 @@ -28,7 +28,7 @@ public final class LoginView: BaseViewController { private let appleLoginButton = SocialLoginButton(socialType: .apple) private var cancellables: Set - public override init(viewModel: LoginViewModel) { + override init(viewModel: LoginViewModel) { cancellables = [] super.init(viewModel: viewModel) } @@ -37,11 +37,11 @@ public final class LoginView: BaseViewController { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() } - public override func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) configureNavigationBar(navigationStyle: .hidden) } @@ -93,15 +93,24 @@ public final class LoginView: BaseViewController { override func bind() { viewModel.output.loginResultPublisher .receive(on: DispatchQueue.main) - .sink { [weak self] loginResult in + .sink { [weak self] userState in guard let self else { return } - if loginResult { - BitnagilLogger.log(logType: .debug, message: "서버 로그인 성공") + guard let userState else { + // TODO: 로그인 실패 시, 에러 처리 + BitnagilLogger.log(logType: .error, message: "서버 로그인 실패") + return + } + + BitnagilLogger.log(logType: .info, message: "서버 로그인 성공") + if userState == .guest { let agreementView = TermsAgreementView(viewModel: self.viewModel) self.navigationController?.pushViewController(agreementView, animated: true) } else { - // TODO: 로그인 실패 시, 에러 처리 - BitnagilLogger.log(logType: .error, message: "서버 로그인 실패") + guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else { + fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.") + } + let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time) + self.navigationController?.pushViewController(onboardingView, animated: true) } } .store(in: &cancellables) @@ -121,7 +130,7 @@ public final class LoginView: BaseViewController { // MARK: - ASAuthorizationControllerDelegate extension LoginView: ASAuthorizationControllerDelegate { - public func authorizationController( + func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { @@ -144,14 +153,14 @@ extension LoginView: ASAuthorizationControllerDelegate { self.viewModel.action(input: .appleLogin(nickname: nickname, authToken: authToken)) } - public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) { + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) { BitnagilLogger.log(logType: .error, message: "Apple 로그인 실패") } } // MARK: - ASAuthorizationControllerPresentationContextProviding extension LoginView: ASAuthorizationControllerPresentationContextProviding { - public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { return view.window ?? UIWindow() } } diff --git a/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift b/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift index 243e6b1a..35e17237 100644 --- a/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift +++ b/Projects/Presentation/Sources/Login/View/TermsAgreementView.swift @@ -13,7 +13,7 @@ import SnapKit import Then import SafariServices -public final class TermsAgreementView: BaseViewController { +final class TermsAgreementView: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 @@ -32,7 +32,7 @@ public final class TermsAgreementView: BaseViewController { private let startButton = PrimaryButton(buttonState: .disabled, buttonTitle: "시작하기") private var cancellables: Set - public override init(viewModel: LoginViewModel) { + override init(viewModel: LoginViewModel) { cancellables = [] super.init(viewModel: viewModel) } @@ -41,11 +41,11 @@ public final class TermsAgreementView: BaseViewController { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() } - public override func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) configureNavigationBar(navigationStyle: .withBackButton(title: "약관 동의")) } diff --git a/Projects/Presentation/Sources/Login/ViewModel/LoginViewModel.swift b/Projects/Presentation/Sources/Login/ViewModel/LoginViewModel.swift index a6f40c57..54a6074a 100644 --- a/Projects/Presentation/Sources/Login/ViewModel/LoginViewModel.swift +++ b/Projects/Presentation/Sources/Login/ViewModel/LoginViewModel.swift @@ -9,8 +9,8 @@ import Combine import Domain import Shared -public final class LoginViewModel: ViewModel { - public enum Input { +final class LoginViewModel: ViewModel { + enum Input { case kakaoLogin case appleLogin(nickname: String?, authToken: String) case toggleAgreement(termsType: TermsType) @@ -18,20 +18,20 @@ public final class LoginViewModel: ViewModel { case submitAgreement } - public struct Output { - let loginResultPublisher: AnyPublisher + struct Output { + let loginResultPublisher: AnyPublisher let agreementStatePublisher: AnyPublisher let agreementResultPublisher: AnyPublisher } private(set) var output: Output - private let loginResultSubject = PassthroughSubject() + private let loginResultSubject = PassthroughSubject() private let agreementStateSubject = CurrentValueSubject(TermsAgreementState()) private let agreementResultSubject = PassthroughSubject() private let loginUseCase: LoginUseCaseProtocol private var userInformation: (nickname: String?, token: String)? - public init(loginUseCase: LoginUseCaseProtocol) { + init(loginUseCase: LoginUseCaseProtocol) { self.loginUseCase = loginUseCase self.output = Output( loginResultPublisher: loginResultSubject.eraseToAnyPublisher(), @@ -40,27 +40,27 @@ public final class LoginViewModel: ViewModel { ) } - public func action(input: Input) { + func action(input: Input) { switch input { case .kakaoLogin: Task { do { - try await loginUseCase.kakaoLogin() - loginResultSubject.send(true) + let user = try await loginUseCase.kakaoLogin() + loginResultSubject.send(user) } catch { BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") - loginResultSubject.send(false) + loginResultSubject.send(nil) } } case .appleLogin(let nickname, let authToken): Task { do { - try await loginUseCase.appleLogin(nickname: nickname, authToken: authToken) - loginResultSubject.send(true) + let user = try await loginUseCase.appleLogin(nickname: nickname, authToken: authToken) + loginResultSubject.send(user) } catch { BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") - loginResultSubject.send(false) + loginResultSubject.send(nil) } } diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift index 3c476039..65fae71c 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift @@ -5,48 +5,9 @@ // Created by 최정인 on 7/9/25. // -enum OnboardingChoiceType: CaseIterable, OnboardingChoiceProtocol { - case morningTime - case eveningTime - case allTime +import Domain - case never - case rarely - case sometimes - case often - - case stability - case connection - case growth - case vitality - - case once - case twoToThree - case fourOrMore - case notSure - - var onboardingType: OnboardingType { - switch self { - case .morningTime: .time - case .eveningTime: .time - case .allTime: .time - - case .never: .frequency - case .rarely: .frequency - case .sometimes: .frequency - case .often: .frequency - - case .stability: .feeling - case .connection: .feeling - case .growth: .feeling - case .vitality: .feeling - - case .once: .outdoorGoal - case .twoToThree: .outdoorGoal - case .fourOrMore: .outdoorGoal - case .notSure: .outdoorGoal - } - } +extension OnboardingChoiceType: OnboardingChoiceProtocol { var mainTitle: String { switch self { diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift index be7b99b9..60302c08 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift @@ -5,18 +5,16 @@ // Created by 최정인 on 7/11/25. // -enum OnboardingType: CaseIterable { - case time - case frequency - case feeling - case outdoorGoal +import Domain +extension OnboardingType { + var step: Int { switch self { case .time: 1 case .frequency: 2 case .feeling: 3 - case .outdoorGoal: 4 + case .outdoor: 4 } } @@ -25,7 +23,7 @@ enum OnboardingType: CaseIterable { case .time: "어떤 시간대를\n더 잘 보내고 싶나요?" case .frequency: "최근 얼마나 자주\n바깥 바람을 쐬시나요?" case .feeling: "요즘 어떤 회복이\n필요하신가요?" - case .outdoorGoal: "작지만 의미 있는 변화를 위해,\n일주일에 몇 번 외출하고 싶으신가요?" + case .outdoor: "작지만 의미 있는 변화를 위해,\n일주일에 몇 번 외출하고 싶으신가요?" } } @@ -34,7 +32,7 @@ enum OnboardingType: CaseIterable { case .time: nil case .frequency: nil case .feeling: "여러 개 선택할 수 있어요!" - case .outdoorGoal: "무리하지 않는 선에서, 나만의 외출 목표를 정해보세요." + case .outdoor: "무리하지 않는 선에서, 나만의 외출 목표를 정해보세요." } } @@ -57,7 +55,7 @@ enum OnboardingType: CaseIterable { OnboardingChoiceType.growth, OnboardingChoiceType.vitality] - case .outdoorGoal: + case .outdoor: return [OnboardingChoiceType.once, OnboardingChoiceType.twoToThree, OnboardingChoiceType.fourOrMore, diff --git a/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift index 671e2158..15c15094 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift @@ -5,9 +5,19 @@ // Created by 최정인 on 7/11/25. // -// TODO: 추후 루틴에 대한 값이 명확해진다면 수정할 가능성이 높습니다. (현재는 View만을 위한 모델) -struct RecommendedRoutine: OnboardingChoiceProtocol, Hashable { +import Domain + +public struct RecommendedRoutine: OnboardingChoiceProtocol, Hashable { let id: Int let mainTitle: String let subTitle: String? } + +extension RecommendedRoutineEntity { + func toRecommendedRoutine() -> RecommendedRoutine { + return RecommendedRoutine( + id: id, + mainTitle: title, + subTitle: description) + } +} diff --git a/Projects/Presentation/Sources/Onboarding/View/Components/GradientProgressView.swift b/Projects/Presentation/Sources/Onboarding/View/Component/GradientProgressView.swift similarity index 100% rename from Projects/Presentation/Sources/Onboarding/View/Components/GradientProgressView.swift rename to Projects/Presentation/Sources/Onboarding/View/Component/GradientProgressView.swift diff --git a/Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift b/Projects/Presentation/Sources/Onboarding/View/Component/OnboardingChoiceButton.swift similarity index 100% rename from Projects/Presentation/Sources/Onboarding/View/Components/OnboardingChoiceButton.swift rename to Projects/Presentation/Sources/Onboarding/View/Component/OnboardingChoiceButton.swift diff --git a/Projects/Presentation/Sources/Onboarding/View/Components/ProgressBarView.swift b/Projects/Presentation/Sources/Onboarding/View/Component/ProgressBarView.swift similarity index 100% rename from Projects/Presentation/Sources/Onboarding/View/Components/ProgressBarView.swift rename to Projects/Presentation/Sources/Onboarding/View/Component/ProgressBarView.swift diff --git a/Projects/Presentation/Sources/Onboarding/View/RecommendedRoutineView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift similarity index 70% rename from Projects/Presentation/Sources/Onboarding/View/RecommendedRoutineView.swift rename to Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift index a04c219c..be644bdf 100644 --- a/Projects/Presentation/Sources/Onboarding/View/RecommendedRoutineView.swift +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift @@ -1,5 +1,5 @@ // -// RecommendedRoutineView.swift +// OnboardingRecommendedRoutineView.swift // Presentation // // Created by 최정인 on 7/11/25. @@ -7,8 +7,10 @@ import UIKit import Combine +import Domain +import Shared -final class RecommendedRoutineView: BaseViewController { +final class OnboardingRecommendedRoutineView: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 @@ -19,7 +21,9 @@ final class RecommendedRoutineView: BaseViewController { static let routineStackViewTopSpacing: CGFloat = 28 static let routineButtonHeight: CGFloat = 84 static let registerButtonHeight: CGFloat = 54 - static let registerButtonBottomSpacing: CGFloat = 20 + static let registerButtonBottomSpacing: CGFloat = 10 + static let skipButtonHeight: CGFloat = 54 + static let skipButtonBottomSpacing: CGFloat = 20 static var mainLabelTopSpacing: CGFloat { let height = UIScreen.main.bounds.height @@ -33,9 +37,11 @@ final class RecommendedRoutineView: BaseViewController { private let recommendedRoutineStackView = UIStackView() private var recommendedRoutines: [Int: OnboardingChoiceButton] = [:] private let registerButton = PrimaryButton(buttonState: .disabled, buttonTitle: "등록하기") + private let skipButtonLabel = UILabel() + private let skipButton = UIButton() private var cancellables: Set - public override init(viewModel: OnboardingViewModel) { + override init(viewModel: OnboardingViewModel) { cancellables = [] super.init(viewModel: viewModel) } @@ -44,19 +50,19 @@ final class RecommendedRoutineView: BaseViewController { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() - viewModel.action(input: .fetchRecommendedRoutine) } - public override func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + viewModel.action(input: .registerOnboarding) let stepCount = OnboardingType.allCases.count + 1 configureNavigationBar(navigationStyle: .withPrograssBarWithCustomBackButton(step: stepCount, stepCount: stepCount)) } - public override func configureAttribute() { + override func configureAttribute() { mainLabel.do { let text = "당신만의 추천 루틴이\n생성되었어요!" $0.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: text) @@ -81,9 +87,23 @@ final class RecommendedRoutineView: BaseViewController { registerButton.addAction(UIAction { [weak self] _ in self?.viewModel.action(input: .registerRecommendedRoutine) }, for: .touchUpInside) + + skipButtonLabel.do { + $0.attributedText = BitnagilFont( + fontSize: 14, + lineHeight: 20, + underline: true, + weight: .regular + ).attributedString(text: "건너뛰기") + $0.textColor = BitnagilColor.navy500 + } + + skipButton.addAction(UIAction { [weak self] _ in + self?.goToHomeView() + }, for: .touchUpInside) } - public override func configureLayout() { + override func configureLayout() { let safeArea = view.safeAreaLayoutGuide view.backgroundColor = BitnagilColor.gray99 @@ -91,6 +111,8 @@ final class RecommendedRoutineView: BaseViewController { view.addSubview(subLabel) view.addSubview(recommendedRoutineStackView) view.addSubview(registerButton) + skipButton.addSubview(skipButtonLabel) + view.addSubview(skipButton) mainLabel.snp.makeConstraints { make in make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) @@ -115,12 +137,23 @@ final class RecommendedRoutineView: BaseViewController { registerButton.snp.makeConstraints { make in make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - make.bottom.equalTo(safeArea).inset(Layout.registerButtonBottomSpacing) + make.bottom.equalTo(skipButton.snp.top).offset(-Layout.registerButtonBottomSpacing) make.height.equalTo(Layout.registerButtonHeight) } + + skipButtonLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + skipButton.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.bottom.equalTo(safeArea).inset(Layout.skipButtonBottomSpacing) + make.height.equalTo(Layout.skipButtonHeight) + } } - public override func bind() { + override func bind() { viewModel.output.recommendedRoutinePublisher .receive(on: DispatchQueue.main) .sink { [weak self] recommendedRoutines in @@ -141,6 +174,18 @@ final class RecommendedRoutineView: BaseViewController { self?.registerButton.updateButtonState(buttonState: canRegister ? .default : .disabled) } .store(in: &cancellables) + + viewModel.output.registerRoutineResultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] registerResult in + if registerResult { + BitnagilLogger.log(logType: .debug, message: "추천 루틴 등록 완료") + self?.goToHomeView() + } else { + BitnagilLogger.log(logType: .error, message: "추천 루틴 등록 실패") + } + } + .store(in: &cancellables) } private func updateRecommendedRoutines(routines: Set) { @@ -175,4 +220,12 @@ final class RecommendedRoutineView: BaseViewController { } } } + + private func goToHomeView() { + guard let homeViewModel = DIContainer.shared.resolve(type: HomeViewModel.self) else { + fatalError("homeViewModel 의존성이 등록되지 않았습니다.") + } + let homeView = HomeViewController(viewModel: homeViewModel) + self.navigationController?.pushViewController(homeView, animated: true) + } } diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift index 9ad68f3c..91d2e51f 100644 --- a/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift @@ -7,6 +7,7 @@ import UIKit import Combine +import Domain final class OnboardingResultView: BaseViewController { @@ -36,7 +37,7 @@ final class OnboardingResultView: BaseViewController { private let graphicView = UIView() private var cancellables: Set - public override init(viewModel: OnboardingViewModel) { + override init(viewModel: OnboardingViewModel) { cancellables = [] super.init(viewModel: viewModel) } @@ -45,31 +46,31 @@ final class OnboardingResultView: BaseViewController { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() viewModel.action(input: .makeOnboardingResult) } - public override func viewDidAppear(_ animated: Bool) { + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UIView.animate(withDuration: 0.5, delay: 3, options: .curveEaseInOut, animations: { self.view.alpha = 0.0 }, completion: { [weak self] finished in guard let self else { return } - let recommendedRoutineView = RecommendedRoutineView(viewModel: self.viewModel) + let recommendedRoutineView = OnboardingRecommendedRoutineView(viewModel: self.viewModel) self.navigationController?.pushViewController(recommendedRoutineView, animated: true) }) } - public override func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) let stepCount = OnboardingType.allCases.count + 1 configureNavigationBar(navigationStyle: .withPrograssBar(step: stepCount, stepCount: stepCount)) } - public override func configureAttribute() { + override func configureAttribute() { mainLabel.do { let text = "이제 당신에게\n꼭 맞는 루틴을 제안해드릴게요." $0.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: text) @@ -99,7 +100,7 @@ final class OnboardingResultView: BaseViewController { } } - public override func configureLayout() { + override func configureLayout() { let safeArea = view.safeAreaLayoutGuide view.backgroundColor = BitnagilColor.gray99 @@ -145,7 +146,7 @@ final class OnboardingResultView: BaseViewController { } } - public override func bind() { + override func bind() { viewModel.output.onboardingResultPublisher .receive(on: DispatchQueue.main) .sink { [weak self] onboardingResults in diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift index 881fae74..dc05635b 100644 --- a/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift @@ -7,6 +7,7 @@ import UIKit import Combine +import Domain final class OnboardingView: BaseViewController { @@ -36,7 +37,7 @@ final class OnboardingView: BaseViewController { private let nextButton = PrimaryButton(buttonState: .disabled, buttonTitle: "다음") private var cancellables: Set - public init(viewModel: OnboardingViewModel, onboarding: OnboardingType) { + init(viewModel: OnboardingViewModel, onboarding: OnboardingType) { self.onboarding = onboarding cancellables = [] super.init(viewModel: viewModel) @@ -46,11 +47,11 @@ final class OnboardingView: BaseViewController { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() } - public override func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) let stepCount = OnboardingType.allCases.count + 1 @@ -59,7 +60,7 @@ final class OnboardingView: BaseViewController { self.viewModel.action(input: .fetchOnboardingChoice(onboarding: onboarding)) } - public override func configureAttribute() { + override func configureAttribute() { mainLabel.do { $0.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: onboarding.mainTitle) $0.textColor = BitnagilColor.navy500 @@ -101,7 +102,7 @@ final class OnboardingView: BaseViewController { }, for: .touchUpInside) } - public override func configureLayout() { + override func configureLayout() { let safeArea = view.safeAreaLayoutGuide view.backgroundColor = BitnagilColor.gray99 @@ -142,7 +143,7 @@ final class OnboardingView: BaseViewController { } } - public override func bind() { + override func bind() { viewModel.output.timeOnboardingChoicePublisher .receive(on: DispatchQueue.main) .sink { [weak self] timeChoice in @@ -164,7 +165,7 @@ final class OnboardingView: BaseViewController { } .store(in: &cancellables) - viewModel.output.outdoorGoalOnboardingChoicePublisher + viewModel.output.outdoorOnboardingChoicePublisher .receive(on: DispatchQueue.main) .sink { [weak self] outdoorChoice in self?.updateOnboardingChoice(onboardingChoice: outdoorChoice) @@ -207,8 +208,8 @@ final class OnboardingView: BaseViewController { case .frequency: nextStep = .feeling case .feeling: - nextStep = .outdoorGoal - case .outdoorGoal: + nextStep = .outdoor + case .outdoor: nextStep = nil } diff --git a/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift index 27b5ebad..1e1b73a1 100644 --- a/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift +++ b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift @@ -6,25 +6,28 @@ // import Combine +import Domain +import Shared final class OnboardingViewModel: ViewModel { - public enum Input { + enum Input { case selectOnboardingChoice(selectedChoice: OnboardingChoiceType) case fetchOnboardingChoice(onboarding: OnboardingType) case makeOnboardingResult - case fetchRecommendedRoutine + case registerOnboarding case selectRoutine(routine: RecommendedRoutine) case registerRecommendedRoutine } - public struct Output { + struct Output { let timeOnboardingChoicePublisher: AnyPublisher let frequencyOnboardingChoicePublisher: AnyPublisher let feelingOnboardingChoicePublisher: AnyPublisher, Never> - let outdoorGoalOnboardingChoicePublisher: AnyPublisher + let outdoorOnboardingChoicePublisher: AnyPublisher let onboardingResultPublisher: AnyPublisher<[String], Never> let recommendedRoutinePublisher: AnyPublisher, Never> let selectedRoutinePublisher: AnyPublisher, Never> + let registerRoutineResultPublisher: AnyPublisher let nextButtonPublisher: AnyPublisher } @@ -32,37 +35,46 @@ final class OnboardingViewModel: ViewModel { private let timeOnboardingChoiceSubject = CurrentValueSubject(nil) private let frequencyOnboardingChoiceSubject = CurrentValueSubject(nil) private let feelingOnboardingChoiceSubject = CurrentValueSubject, Never>([]) - private let outdoorGoalOnboardingChoiceSubject = CurrentValueSubject(nil) + private let outdoorOnboardingChoiceSubject = CurrentValueSubject(nil) private let onboardingResultSubject = CurrentValueSubject<[String], Never>([]) private let recommendedRoutineSubject = CurrentValueSubject, Never>([]) private let selectedRoutineSubject = CurrentValueSubject, Never>([]) + private let registerRoutineResultSubject = PassthroughSubject() private let nextButtonSubject = PassthroughSubject() - public init() { + private let onboardingUseCase: OnboardingUseCaseProtocol + init(onboardingUseCase: OnboardingUseCaseProtocol) { + self.onboardingUseCase = onboardingUseCase self.output = Output( timeOnboardingChoicePublisher: timeOnboardingChoiceSubject.eraseToAnyPublisher(), frequencyOnboardingChoicePublisher: frequencyOnboardingChoiceSubject.eraseToAnyPublisher(), feelingOnboardingChoicePublisher: feelingOnboardingChoiceSubject.eraseToAnyPublisher(), - outdoorGoalOnboardingChoicePublisher: outdoorGoalOnboardingChoiceSubject.eraseToAnyPublisher(), + outdoorOnboardingChoicePublisher: outdoorOnboardingChoiceSubject.eraseToAnyPublisher(), onboardingResultPublisher: onboardingResultSubject.eraseToAnyPublisher(), recommendedRoutinePublisher: recommendedRoutineSubject.eraseToAnyPublisher(), selectedRoutinePublisher: selectedRoutineSubject.eraseToAnyPublisher(), + registerRoutineResultPublisher: registerRoutineResultSubject.eraseToAnyPublisher(), nextButtonPublisher: nextButtonSubject.eraseToAnyPublisher() ) } - public func action(input: Input) { + func action(input: Input) { switch input { case .selectOnboardingChoice(let selectedChoice): selectChoice(choice: selectedChoice) + case .fetchOnboardingChoice(let onboarding): fetchChoice(onboarding: onboarding) + case .makeOnboardingResult: makeOnboardingResult() - case .fetchRecommendedRoutine: - fetchRecommendedRoutine() + + case .registerOnboarding: + registerOnboarding() + case .selectRoutine(let routine): selectRoutine(routine: routine) + case .registerRecommendedRoutine: registerRecommendedRoutine() } @@ -83,16 +95,16 @@ final class OnboardingViewModel: ViewModel { frequencyOnboardingChoiceSubject.send(frequencyOnboardingChoiceSubject.value) updateNextButtonSubject(choiceSubject: frequencyOnboardingChoiceSubject) - case .outdoorGoal: - outdoorGoalOnboardingChoiceSubject.send(outdoorGoalOnboardingChoiceSubject.value) - updateNextButtonSubject(choiceSubject: outdoorGoalOnboardingChoiceSubject) + case .outdoor: + outdoorOnboardingChoiceSubject.send(outdoorOnboardingChoiceSubject.value) + updateNextButtonSubject(choiceSubject: outdoorOnboardingChoiceSubject) } } // 온보딩 선택지를 선택합니다. private func selectChoice(choice: OnboardingChoiceType) { switch choice.onboardingType { - case .time, .frequency, .outdoorGoal: + case .time, .frequency, .outdoor: selectOnlyOneChoice(choice: choice) case .feeling: selecteMultipleChoices(choice: choice) @@ -107,10 +119,10 @@ final class OnboardingViewModel: ViewModel { onboardSubject = timeOnboardingChoiceSubject case .frequency: onboardSubject = frequencyOnboardingChoiceSubject - case .outdoorGoal: - onboardSubject = outdoorGoalOnboardingChoiceSubject + case .outdoor: + onboardSubject = outdoorOnboardingChoiceSubject default: - onboardSubject = outdoorGoalOnboardingChoiceSubject + onboardSubject = outdoorOnboardingChoiceSubject } let currentChoice = onboardSubject.value @@ -161,27 +173,44 @@ final class OnboardingViewModel: ViewModel { guard let timeOnboardingChoice = timeOnboardingChoiceSubject.value, - let outdoorGoalOnboardingChoice = outdoorGoalOnboardingChoiceSubject.value, + let outdoorOnboardingChoice = outdoorOnboardingChoiceSubject.value, let timeResult = timeOnboardingChoice.resultTitle, - let outdoorGoalResult = outdoorGoalOnboardingChoice.resultTitle + let outdoorResult = outdoorOnboardingChoice.resultTitle else { return } - let result = [timeResult, feelingResult, outdoorGoalResult] + let result = [timeResult, feelingResult, outdoorResult] onboardingResultSubject.send(result) } - // 온보딩 결과를 바탕으로 추천 루틴을 불러옵니다. - private func fetchRecommendedRoutine() { - recommendedRoutineSubject.send([]) - // TODO: 서버 API 만들어진 후 UseCase와 연동하는 작업이 필요합니다. - let recommendedRoutines: [RecommendedRoutine] = [ - RecommendedRoutine(id: 1, mainTitle: "루틴명", subTitle: "세부 루틴 한 줄 설명"), - RecommendedRoutine(id: 2, mainTitle: "루틴명", subTitle: "세부 루틴 한 줄 설명"), - RecommendedRoutine(id: 3, mainTitle: "루틴명", subTitle: "세부 루틴 한 줄 설명") - ] - recommendedRoutineSubject.send(Set(recommendedRoutines)) + // 온보딩 결과를 등록하고, 그 결과를 바탕으로 추천 루틴을 받아옵니다. + private func registerOnboarding() { + var onboardingChoices: [OnboardingChoiceType] = [] + + let feelingOnboarding = Array(feelingOnboardingChoiceSubject.value) + guard + let timeOnboarding = timeOnboardingChoiceSubject.value, + !feelingOnboarding.isEmpty, + let frequencyOnboarding = frequencyOnboardingChoiceSubject.value, + let outdoorOnboarding = outdoorOnboardingChoiceSubject.value + else { return } + + onboardingChoices.append(timeOnboarding) + onboardingChoices += feelingOnboarding + onboardingChoices.append(frequencyOnboarding) + onboardingChoices.append(outdoorOnboarding) + + Task { + do { + let entities = try await onboardingUseCase.registerOnboarding(onboardingChoices: onboardingChoices) + let recommendedRoutines = entities.map({ $0.toRecommendedRoutine() }) + recommendedRoutineSubject.send([]) + recommendedRoutineSubject.send(Set(recommendedRoutines)) + } catch { + BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + } + } } // 추천 루틴을 선택합니다. @@ -199,6 +228,16 @@ final class OnboardingViewModel: ViewModel { // 추천 루틴을 등록합니다. private func registerRecommendedRoutine() { - // TODO: 서버 API 만들어진 후 UseCase와 연동하는 작업이 필요합니다. + let selectedRoutinesId = selectedRoutineSubject.value.map({ $0.id }) + + Task { + do { + try await onboardingUseCase.registerRecommendedRoutines(selectedRoutines: selectedRoutinesId) + registerRoutineResultSubject.send(true) + } catch { + BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + registerRoutineResultSubject.send(false) + } + } } } diff --git a/Projects/Presentation/Sources/Protocols/BaseViewController.swift b/Projects/Presentation/Sources/Protocol/BaseViewController.swift similarity index 85% rename from Projects/Presentation/Sources/Protocols/BaseViewController.swift rename to Projects/Presentation/Sources/Protocol/BaseViewController.swift index 1f680c0e..ff36892a 100644 --- a/Projects/Presentation/Sources/Protocols/BaseViewController.swift +++ b/Projects/Presentation/Sources/Protocol/BaseViewController.swift @@ -8,8 +8,8 @@ import Foundation import UIKit -public class BaseViewController: UIViewController { - public let viewModel: T +class BaseViewController: UIViewController { + let viewModel: T init(viewModel: T) { self.viewModel = viewModel @@ -20,7 +20,7 @@ public class BaseViewController: UIViewController { fatalError("init(coder:) has not been implemented") } - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() configureAttribute() diff --git a/Projects/Presentation/Sources/Protocols/ViewModel.swift b/Projects/Presentation/Sources/Protocol/ViewModel.swift similarity index 86% rename from Projects/Presentation/Sources/Protocols/ViewModel.swift rename to Projects/Presentation/Sources/Protocol/ViewModel.swift index 439262fd..6b630c53 100644 --- a/Projects/Presentation/Sources/Protocols/ViewModel.swift +++ b/Projects/Presentation/Sources/Protocol/ViewModel.swift @@ -7,7 +7,7 @@ import Foundation -public protocol ViewModel { +protocol ViewModel { associatedtype Input associatedtype Output