diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index 7b70c841..153c99b8 100644 --- a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift +++ b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift @@ -28,5 +28,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: RecommendedRoutineRepositoryProtocol.self) { _ in return RecommendedRoutineRepository() } + + DIContainer.shared.register(type: EmotionRepositoryProtocol.self) { _ in + return EmotionRepository() + } } } diff --git a/Projects/DataSource/Sources/DTO/EmotionResponseDTO.swift b/Projects/DataSource/Sources/DTO/EmotionResponseDTO.swift new file mode 100644 index 00000000..73be1811 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/EmotionResponseDTO.swift @@ -0,0 +1,30 @@ +// +// EmotionResponseDTO.swift +// DataSource +// +// Created by 최정인 on 7/29/25. +// + +import Domain +import Foundation + +struct EmotionResponseDTO: Decodable { + let type: String + let name: String + let imageUrl: String + + enum CodingKeys: String, CodingKey { + case type = "emotionMarbleType" + case name = "emotionMarbleName" + case imageUrl + } +} + +extension EmotionResponseDTO { + func toEmotionEntity() -> EmotionEntity { + return EmotionEntity( + emotionType: type, + emotionName: name, + emotionImageUrl: URL(string: imageUrl)) + } +} diff --git a/Projects/DataSource/Sources/Endpoint/EmotionEndpoint.swift b/Projects/DataSource/Sources/Endpoint/EmotionEndpoint.swift new file mode 100644 index 00000000..ac56ae13 --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/EmotionEndpoint.swift @@ -0,0 +1,53 @@ +// +// EmotionEndpoint.swift +// DataSource +// +// Created by 최정인 on 7/28/25. +// + +enum EmotionEndpoint { + case fetchEmotions + case registerEmotion(emotion: String) +} + +extension EmotionEndpoint: Endpoint { + var baseURL: String { + return AppProperties.baseURL + "/api/v1/emotion-marbles" + } + + var path: String { + return baseURL + } + + var method: HTTPMethod { + switch self { + case .fetchEmotions: .get + case .registerEmotion: .post + } + } + + var headers: [String : String] { + let headers: [String: String] = [ + "Content-Type": "application/json", + "accept": "*/*" + ] + return headers + } + + var queryParameters: [String : String] { + return [:] + } + + var bodyParameters: [String : Any] { + switch self { + case .fetchEmotions: + return [:] + case .registerEmotion(let emotion): + return ["emotionMarbleType": emotion] + } + } + + var isAuthorized: Bool { + return true + } +} diff --git a/Projects/DataSource/Sources/Repository/EmotionRepository.swift b/Projects/DataSource/Sources/Repository/EmotionRepository.swift new file mode 100644 index 00000000..d2ed0de1 --- /dev/null +++ b/Projects/DataSource/Sources/Repository/EmotionRepository.swift @@ -0,0 +1,30 @@ +// +// EmotionRepository.swift +// DataSource +// +// Created by 최정인 on 7/28/25. +// + +import Domain + +final class EmotionRepository: EmotionRepositoryProtocol { + private let networkService = NetworkService.shared + + func fetchEmotions() async throws -> [EmotionEntity] { + let endpoint = EmotionEndpoint.fetchEmotions + guard let response = try await networkService.request(endpoint: endpoint, type: [EmotionResponseDTO].self) + else { return [] } + + let emotionEntities = response.compactMap({ $0.toEmotionEntity() }) + return emotionEntities + } + + func registerEmotion(emotion: String) async throws -> [RecommendedRoutineEntity] { + let endpoint = EmotionEndpoint.registerEmotion(emotion: emotion) + guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self) + else { return [] } + + let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() }) + return recommendedRoutineEntity + } +} diff --git a/Projects/Domain/Sources/DomainDependencyAssembler.swift b/Projects/Domain/Sources/DomainDependencyAssembler.swift index f20a954a..5ea14c30 100644 --- a/Projects/Domain/Sources/DomainDependencyAssembler.swift +++ b/Projects/Domain/Sources/DomainDependencyAssembler.swift @@ -32,18 +32,25 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { 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) - } - DIContainer.shared.register(type: RecommendedRoutineUseCaseProtocol.self) { container in guard let recommendedRoutineRepository = container.resolve(type: RecommendedRoutineRepositoryProtocol.self) else { fatalError("recommendedRoutineRepository 의존성이 등록되지 않았습니다.") } return RecommendedRoutineUseCase(recommendedRoutineRepository: recommendedRoutineRepository) } + + guard let emotionRepository = DIContainer.shared.resolve(type: EmotionRepositoryProtocol.self) + else { fatalError("emotionRepository 의존성이 등록되지 않았습니다.") } + + DIContainer.shared.register(type: EmotionUseCaseProtocol.self) { _ in + return EmotionUseCase(emotionRepository: emotionRepository) + } + + DIContainer.shared.register(type: ResultRecommendedRoutineUseCaseProtocol.self) { container in + guard let onboardingRepository = container.resolve(type: OnboardingRepositoryProtocol.self) + else { fatalError("onboardingRepository 의존성이 등록되지 않았습니다.") } + + return ResultRecommendedRoutineUseCase(onboardingRepository: onboardingRepository, emotionRepository: emotionRepository) + } } } diff --git a/Projects/Domain/Sources/Entity/EmotionEntity.swift b/Projects/Domain/Sources/Entity/EmotionEntity.swift new file mode 100644 index 00000000..785526bd --- /dev/null +++ b/Projects/Domain/Sources/Entity/EmotionEntity.swift @@ -0,0 +1,24 @@ +// +// EmotionEntity.swift +// Domain +// +// Created by 최정인 on 7/29/25. +// + +import Foundation + +public struct EmotionEntity { + public let emotionType: String + public let emotionName: String + public let emotionImageUrl: URL? + + public init( + emotionType: String, + emotionName: String, + emotionImageUrl: URL? + ) { + self.emotionType = emotionType + self.emotionName = emotionName + self.emotionImageUrl = emotionImageUrl + } +} diff --git a/Projects/Domain/Sources/Protocol/Repository/EmotionRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/EmotionRepositoryProtocol.swift new file mode 100644 index 00000000..fcc3a7ae --- /dev/null +++ b/Projects/Domain/Sources/Protocol/Repository/EmotionRepositoryProtocol.swift @@ -0,0 +1,18 @@ +// +// EmotionRepositoryProtocol.swift +// Domain +// +// Created by 최정인 on 7/28/25. +// + +/// 감정 구슬에 대한 로직을 처리하는 Repository +public protocol EmotionRepositoryProtocol { + /// 감정 구슬 목록을 불러옵니다. + /// - Returns: 조회된 감정 구슬 목록 + func fetchEmotions() async throws -> [EmotionEntity] + + /// 감정 구슬을 등록합니다. + /// - Parameter emotion: 감정 구슬 String 값 + /// - Returns: 등록한 감정 구슬에 따른 추천 루틴 리스트 + func registerEmotion(emotion: String) async throws -> [RecommendedRoutineEntity] +} diff --git a/Projects/Domain/Sources/Protocol/UseCase/EmotionUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/EmotionUseCaseProtocol.swift new file mode 100644 index 00000000..c4d85fdb --- /dev/null +++ b/Projects/Domain/Sources/Protocol/UseCase/EmotionUseCaseProtocol.swift @@ -0,0 +1,12 @@ +// +// EmotionUseCaseProtocol.swift +// Domain +// +// Created by 최정인 on 7/28/25. +// + +public protocol EmotionUseCaseProtocol { + /// 감정 구슬 목록을 불러옵니다. + /// - Returns: 조회된 감정 구슬 목록 + func fetchEmotions() async throws -> [EmotionEntity] +} diff --git a/Projects/Domain/Sources/Protocol/UseCase/OnboardingUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/OnboardingUseCaseProtocol.swift deleted file mode 100644 index 7019a28e..00000000 --- a/Projects/Domain/Sources/Protocol/UseCase/OnboardingUseCaseProtocol.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// 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/Protocol/UseCase/ResultRecommendedRoutineUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/ResultRecommendedRoutineUseCaseProtocol.swift new file mode 100644 index 00000000..81cfe6e6 --- /dev/null +++ b/Projects/Domain/Sources/Protocol/UseCase/ResultRecommendedRoutineUseCaseProtocol.swift @@ -0,0 +1,24 @@ +// +// ResultRecommendedRoutineUseCaseProtocol.swift +// Domain +// +// Created by 최정인 on 7/29/25. +// + +import Foundation + +public protocol ResultRecommendedRoutineUseCaseProtocol { + /// 선택한 온보딩 결과를 저장하고, 추천 루틴을 받습니다. + /// - Parameter onboardingChoices: 선택한 온보딩 항목 list + /// - Returns: 온보딩 결과를 바탕으로 받은 추천루틴 목록 + func fetchResultRecommendedRoutines(onboardingChoices: [OnboardingChoiceType]) async throws -> [RecommendedRoutineEntity] + + /// 감정 구슬을 등록하고 그에 따른 추천 루틴 리스트를 받습니다. + /// - Parameter emotion: 감정 구슬 타입 String + /// - Returns: 등록한 감정 구슬에 따른 추천 루틴 리스트 + func fetchResultRecommendedRoutines(emotion: String) async throws -> [RecommendedRoutineEntity] + + /// 선택한 추천 루틴을 등록합니다. + /// - Parameter selectedRoutines: 선택한 추천 루틴 ID 목록 + func registerRecommendedRoutines(selectedRoutines: [Int]) async throws +} diff --git a/Projects/Domain/Sources/UseCase/Emotion/EmotionUseCase.swift b/Projects/Domain/Sources/UseCase/Emotion/EmotionUseCase.swift new file mode 100644 index 00000000..b433d59a --- /dev/null +++ b/Projects/Domain/Sources/UseCase/Emotion/EmotionUseCase.swift @@ -0,0 +1,19 @@ +// +// EmotionUseCase.swift +// Domain +// +// Created by 최정인 on 7/28/25. +// + +public final class EmotionUseCase: EmotionUseCaseProtocol { + private let emotionRepository: EmotionRepositoryProtocol + + public init(emotionRepository: EmotionRepositoryProtocol) { + self.emotionRepository = emotionRepository + } + + public func fetchEmotions() async throws -> [EmotionEntity] { + let emotions = try await emotionRepository.fetchEmotions() + return emotions + } +} diff --git a/Projects/Domain/Sources/UseCase/Onboarding/OnboardingUseCase.swift b/Projects/Domain/Sources/UseCase/ResultRecommendedRoutine/ResultRecommendedRoutineUseCase.swift similarity index 61% rename from Projects/Domain/Sources/UseCase/Onboarding/OnboardingUseCase.swift rename to Projects/Domain/Sources/UseCase/ResultRecommendedRoutine/ResultRecommendedRoutineUseCase.swift index ae53ccbf..f344058e 100644 --- a/Projects/Domain/Sources/UseCase/Onboarding/OnboardingUseCase.swift +++ b/Projects/Domain/Sources/UseCase/ResultRecommendedRoutine/ResultRecommendedRoutineUseCase.swift @@ -1,23 +1,31 @@ // -// OnboardingUseCase.swift +// ResultRecommendedRoutineUseCase.swift // Domain // -// Created by 최정인 on 7/15/25. +// Created by 최정인 on 7/29/25. // -public final class OnboardingUseCase: OnboardingUseCaseProtocol { + +public final class ResultRecommendedRoutineUseCase: ResultRecommendedRoutineUseCaseProtocol { private let onboardingRepository: OnboardingRepositoryProtocol + private let emotionRepository: EmotionRepositoryProtocol - public init(onboardingRepository: OnboardingRepositoryProtocol) { + public init(onboardingRepository: OnboardingRepositoryProtocol, emotionRepository: EmotionRepositoryProtocol) { self.onboardingRepository = onboardingRepository + self.emotionRepository = emotionRepository } - public func registerOnboarding(onboardingChoices: [OnboardingChoiceType]) async throws -> [RecommendedRoutineEntity] { + public func fetchResultRecommendedRoutines(onboardingChoices: [OnboardingChoiceType]) async throws -> [RecommendedRoutineEntity] { let choices = convertToDictionary(onboardingChoices: onboardingChoices) let recommendedRoutines = try await onboardingRepository.registerOnboarding(onboardingChoices: choices) return recommendedRoutines } + public func fetchResultRecommendedRoutines(emotion: String) async throws -> [RecommendedRoutineEntity] { + let recommendedRoutines = try await emotionRepository.registerEmotion(emotion: emotion) + return recommendedRoutines + } + public func registerRecommendedRoutines(selectedRoutines: [Int]) async throws { try await onboardingRepository.registerRecommendedRoutines(selectedRoutines: selectedRoutines) } diff --git a/Projects/Presentation/Project.swift b/Projects/Presentation/Project.swift index c85074f1..13c23c64 100644 --- a/Projects/Presentation/Project.swift +++ b/Projects/Presentation/Project.swift @@ -13,6 +13,7 @@ let project = Project( resources: ["Resources/**"], dependencies: [ .external(name: "SnapKit"), + .external(name: "Kingfisher"), .project(target: "Domain", path: "../Domain"), .project(target: "Shared", path: "../Shared") ] diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/Contents.json deleted file mode 100644 index 7b34eb97..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "anxiety_orb.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "anxiety_orb@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "anxiety_orb@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb.png deleted file mode 100644 index 132d6f2a..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb@2x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb@2x.png deleted file mode 100644 index f92660b5..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb@3x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb@3x.png deleted file mode 100644 index cf6c1db6..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/anxiety_orb.imageset/anxiety_orb@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/Contents.json deleted file mode 100644 index 3fba1229..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "calm_orb.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "calm_orb@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "calm_orb@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb.png deleted file mode 100644 index 8b0dbf4f..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb@2x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb@2x.png deleted file mode 100644 index 1a3b62f5..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb@3x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb@3x.png deleted file mode 100644 index 8d473c67..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/calm_orb.imageset/calm_orb@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/Contents.json deleted file mode 100644 index d6b841da..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "lethargy_orb.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "lethargy_orb@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "lethargy_orb@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb.png deleted file mode 100644 index 710aabca..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb@2x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb@2x.png deleted file mode 100644 index 1f98190b..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb@3x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb@3x.png deleted file mode 100644 index 99a8b17d..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/lethargy_orb.imageset/lethargy_orb@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb.png deleted file mode 100644 index b7ee6a0e..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb@2x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb@2x.png deleted file mode 100644 index 91e1b419..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb@3x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb@3x.png deleted file mode 100644 index 5a53e50d..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/satisfied_orb@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/Contents.json deleted file mode 100644 index 3565ad77..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "tired_orb.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "tired_orb@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "tired_orb@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb.png deleted file mode 100644 index 0444e957..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb@2x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb@2x.png deleted file mode 100644 index c7763355..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb@3x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb@3x.png deleted file mode 100644 index e419785e..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/tired_orb.imageset/tired_orb@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/Contents.json deleted file mode 100644 index 7620096b..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "vitality_orb.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "vitality_orb@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "vitality_orb@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb.png deleted file mode 100644 index 99de8edd..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb@2x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb@2x.png deleted file mode 100644 index 945b9f3c..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb@3x.png b/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb@3x.png deleted file mode 100644 index b47a240a..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/vitality_orb.imageset/vitality_orb@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Graphic/Contents.json similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/EmotionOrb/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/Graphic/Contents.json diff --git a/Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/Contents.json similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/Contents.json diff --git a/Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/IntroGraphic.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/IntroGraphic.png similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/IntroGraphic.png rename to Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/IntroGraphic.png diff --git a/Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/IntroGraphic@2x.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/IntroGraphic@2x.png similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/IntroGraphic@2x.png rename to Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/IntroGraphic@2x.png diff --git a/Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/IntroGraphic@3x.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/IntroGraphic@3x.png similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/IntroGraphic.imageset/IntroGraphic@3x.png rename to Projects/Presentation/Resources/Images.xcassets/Graphic/intro_graphic.imageset/IntroGraphic@3x.png diff --git a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/Contents.json similarity index 65% rename from Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/Contents.json index 3fdda8af..d8bccaf2 100644 --- a/Projects/Presentation/Resources/Images.xcassets/EmotionOrb/satisfied_orb.imageset/Contents.json +++ b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "satisfied_orb.png", + "filename" : "onboarding_graphic.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "satisfied_orb@2x.png", + "filename" : "onboarding_graphic@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "satisfied_orb@3x.png", + "filename" : "onboarding_graphic@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic.png new file mode 100644 index 00000000..15a43b51 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic@2x.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic@2x.png new file mode 100644 index 00000000..c20cc002 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic@3x.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic@3x.png new file mode 100644 index 00000000..f29abc5d Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/Graphic/onboarding_graphic.imageset/onboarding_graphic@3x.png differ diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift index 5958de00..0c622f72 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift @@ -12,13 +12,6 @@ enum BitnagilGraphic { return Bundle(for: IntroView.self) } - static let introGraphic = UIImage(named: "IntroGraphic", in: bundle, with: nil) - - // MARK: - Emotion Orb - static let calmOrb = UIImage(named: "calm_orb", in: bundle, with: nil) - static let lethargyOrb = UIImage(named: "lethargy_orb", in: bundle, with: nil) - static let vitalityOrb = UIImage(named: "vitality_orb", in: bundle, with: nil) - static let anxietyOrb = UIImage(named: "anxiety_orb", in: bundle, with: nil) - static let satisfiedOrb = UIImage(named: "satisfied_orb", in: bundle, with: nil) - static let tiredOrb = UIImage(named: "tired_orb", in: bundle, with: nil) + static let introGraphic = UIImage(named: "intro_graphic", in: bundle, with: nil) + static let onboardingGraphic = UIImage(named: "onboarding_graphic", in: bundle, with: nil) } diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 1d6d1c1c..4457d559 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -29,11 +29,8 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { 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) + DIContainer.shared.register(type: OnboardingViewModel.self) { _ in + return OnboardingViewModel() } DIContainer.shared.register(type: RecommendedRoutineViewModel.self) { container in @@ -50,12 +47,22 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return MypageViewModel(userDataRepository: userDataRepository) } - DIContainer.shared.register(type: EmotionRegisterViewModel.self) { _ in - return EmotionRegisterViewModel() + DIContainer.shared.register(type: EmotionRegisterViewModel.self) { container in + guard let emotionUseCase = container.resolve(type: EmotionUseCaseProtocol.self) + else { fatalError("emotionUseCase 의존성이 등록되지 않았습니다.") } + + return EmotionRegisterViewModel(emotionUseCase: emotionUseCase) } DIContainer.shared.register(type: RoutineCreationViewModel.self) { _ in return RoutineCreationViewModel() } + + DIContainer.shared.register(type: ResultRecommendedRoutineViewModel.self) { container in + guard let resultRecommendedRoutineUseCase = container.resolve(type: ResultRecommendedRoutineUseCaseProtocol.self) + else { fatalError("resultRecommendedRoutineUseCase 의존성이 등록되지 않았습니다.") } + + return ResultRecommendedRoutineViewModel(resultRecommendedRoutineUseCase: resultRecommendedRoutineUseCase) + } } } diff --git a/Projects/Presentation/Sources/Common/SplashView.swift b/Projects/Presentation/Sources/Common/View/SplashView.swift similarity index 100% rename from Projects/Presentation/Sources/Common/SplashView.swift rename to Projects/Presentation/Sources/Common/View/SplashView.swift diff --git a/Projects/Presentation/Sources/Common/TabBarView.swift b/Projects/Presentation/Sources/Common/View/TabBarView.swift similarity index 100% rename from Projects/Presentation/Sources/Common/TabBarView.swift rename to Projects/Presentation/Sources/Common/View/TabBarView.swift diff --git a/Projects/Presentation/Sources/EmotionRegister/Model/Emotion.swift b/Projects/Presentation/Sources/EmotionRegister/Model/Emotion.swift new file mode 100644 index 00000000..b3431a0f --- /dev/null +++ b/Projects/Presentation/Sources/EmotionRegister/Model/Emotion.swift @@ -0,0 +1,24 @@ +// +// Emotion.swift +// Presentation +// +// Created by 최정인 on 7/29/25. +// + +import Domain +import Foundation + +struct Emotion { + let emotionType: String + let emotionTitle: String + let emotionImageUrl: URL? +} + +extension EmotionEntity { + func toEmotion() -> Emotion { + return Emotion( + emotionType: emotionType, + emotionTitle: emotionName, + emotionImageUrl: emotionImageUrl) + } +} diff --git a/Projects/Presentation/Sources/EmotionRegister/Model/EmotionType.swift b/Projects/Presentation/Sources/EmotionRegister/Model/EmotionType.swift deleted file mode 100644 index 60c836a7..00000000 --- a/Projects/Presentation/Sources/EmotionRegister/Model/EmotionType.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// EmotionType.swift -// Presentation -// -// Created by 최정인 on 7/28/25. -// - -import UIKit - -enum EmotionType: CaseIterable { - case calm - case lethargy - case vitality - case anxiety - case satisfied - case tired - - var title: String { - switch self { - case .calm: "평온함" - case .lethargy: "무기력함" - case .vitality: "활기참" - case .anxiety: "불안함" - case .satisfied: "만족함" - case .tired: "피로함" - } - } - - var image: UIImage? { - switch self { - case .calm: BitnagilGraphic.calmOrb - case .lethargy: BitnagilGraphic.lethargyOrb - case .vitality: BitnagilGraphic.vitalityOrb - case .anxiety: BitnagilGraphic.anxietyOrb - case .satisfied: BitnagilGraphic.satisfiedOrb - case .tired: BitnagilGraphic.tiredOrb - } - } -} diff --git a/Projects/Presentation/Sources/EmotionRegister/View/Component/EmotionOrbCollectionViewCell.swift b/Projects/Presentation/Sources/EmotionRegister/View/Component/EmotionOrbCollectionViewCell.swift index 1952ee63..ab3dfd74 100644 --- a/Projects/Presentation/Sources/EmotionRegister/View/Component/EmotionOrbCollectionViewCell.swift +++ b/Projects/Presentation/Sources/EmotionRegister/View/Component/EmotionOrbCollectionViewCell.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/28/25. // +import Kingfisher import SnapKit import UIKit @@ -53,8 +54,11 @@ final class EmotionOrbCollectionViewCell: UICollectionViewCell { } } - func configureCell(emotion: EmotionType) { - emotionOrbImage.image = emotion.image - emotionLabel.text = emotion.title + func configureCell(emotion: Emotion) { + emotionLabel.text = emotion.emotionTitle + + if let imageUrl = emotion.emotionImageUrl { + emotionOrbImage.kf.setImage(with: imageUrl) + } } } diff --git a/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegisterView.swift b/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegisterView.swift index 26793404..38fcea97 100644 --- a/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegisterView.swift +++ b/Projects/Presentation/Sources/EmotionRegister/View/EmotionRegisterView.swift @@ -38,7 +38,7 @@ final class EmotionRegisterView: BaseViewController { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) return collectionView }() - + private var emotionList: [Emotion] = [] private var cancellables: Set override init(viewModel: EmotionRegisterViewModel) { @@ -52,6 +52,7 @@ final class EmotionRegisterView: BaseViewController { override func viewDidLoad() { super.viewDidLoad() + viewModel.action(input: .fetchEmotions) } override func viewWillAppear(_ animated: Bool) { @@ -106,14 +107,12 @@ final class EmotionRegisterView: BaseViewController { } override func bind() { - viewModel.output.registerEmotionResultPublisher + viewModel.output.emotionListPublisher .receive(on: DispatchQueue.main) - .sink { [weak self] registerEmotionResult in - if registerEmotionResult { - // TODO: 추천 루틴 화면 보여주기 - BitnagilLogger.log(logType: .error, message: "감정 등록 성공") - } else { - BitnagilLogger.log(logType: .error, message: "감정 등록 실패") + .sink { [weak self] emotionList in + self?.emotionList = emotionList + if !emotionList.isEmpty { + self?.emotionOrbCollectionView.reloadData() } } .store(in: &cancellables) @@ -123,22 +122,27 @@ final class EmotionRegisterView: BaseViewController { // MARK: UICollectionViewDelegate extension EmotionRegisterView: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let selectedEmotionType = EmotionType.allCases[indexPath.item] - viewModel.action(input: .selectEmotion(emotion: selectedEmotionType)) + let selectedEmotion = emotionList[indexPath.item] + guard let resultRecommendedRoutineViewModel = DIContainer.shared.resolve(type: ResultRecommendedRoutineViewModel.self) + else { fatalError("resultRecommendedRoutineViewModel 의존성이 등록되지 않았습니다.") } + resultRecommendedRoutineViewModel.configure(viewModelType: .emotion(emotion: selectedEmotion)) + + let resultRecommendedRoutineView = ResultRecommendedRoutineView(entryPoint: .emotion, viewModel: resultRecommendedRoutineViewModel) + self.navigationController?.pushViewController(resultRecommendedRoutineView, animated: true) } } // MARK: UICollectionViewDataSource extension EmotionRegisterView: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return EmotionType.allCases.count + return emotionList.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: EmotionOrbCollectionViewCell.className, for: indexPath) as? EmotionOrbCollectionViewCell else { return UICollectionViewCell() } - let emotion = EmotionType.allCases[indexPath.item] + let emotion = emotionList[indexPath.item] cell.configureCell(emotion: emotion) return cell } diff --git a/Projects/Presentation/Sources/EmotionRegister/ViewModel/EmotionRegisterViewModel.swift b/Projects/Presentation/Sources/EmotionRegister/ViewModel/EmotionRegisterViewModel.swift index 47646c24..3e7c7b20 100644 --- a/Projects/Presentation/Sources/EmotionRegister/ViewModel/EmotionRegisterViewModel.swift +++ b/Projects/Presentation/Sources/EmotionRegister/ViewModel/EmotionRegisterViewModel.swift @@ -6,33 +6,47 @@ // import Combine +import Domain +import Shared final class EmotionRegisterViewModel: ViewModel { enum Input { - case selectEmotion(emotion: EmotionType) + case fetchEmotions } struct Output { - let registerEmotionResultPublisher: AnyPublisher + let emotionListPublisher: AnyPublisher<[Emotion], Never> } private(set) var output: Output - private let registerEmotionResultSubject = PassthroughSubject() - init() { + private let emotionListSubject = CurrentValueSubject<[Emotion], Never>([]) + + private let emotionUseCase: EmotionUseCaseProtocol + init(emotionUseCase: EmotionUseCaseProtocol) { + self.emotionUseCase = emotionUseCase output = Output( - registerEmotionResultPublisher: registerEmotionResultSubject.eraseToAnyPublisher() + emotionListPublisher: emotionListSubject.eraseToAnyPublisher() ) } func action(input: Input) { switch input { - case .selectEmotion(let emotion): - registerEmotion(emotion: emotion) + case .fetchEmotions: + fetchEmotions() } } - private func registerEmotion(emotion: EmotionType) { - // TODO: 서버 통신 로직 - registerEmotionResultSubject.send(true) + private func fetchEmotions() { + Task { + do { + let emotionEntities = try await emotionUseCase.fetchEmotions() + let emotionList = emotionEntities.compactMap({ $0.toEmotion() }) + BitnagilLogger.log(logType: .info, message: "감정 구슬 목록들 조회에 성공했습니다.") + emotionListSubject.send(emotionList) + } catch { + // TODO: 에러 처리 토스트뷰 + BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + } + } } } diff --git a/Projects/Presentation/Sources/MyPage/View/MypageView.swift b/Projects/Presentation/Sources/MyPage/View/MypageView.swift index f2749b25..407ff347 100644 --- a/Projects/Presentation/Sources/MyPage/View/MypageView.swift +++ b/Projects/Presentation/Sources/MyPage/View/MypageView.swift @@ -158,7 +158,11 @@ extension MypageView: UITableViewDataSource { fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.") } - let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time) + let onboardingView = OnboardingView( + viewModel: onboardingViewModel, + onboarding: .time, + isFromMypage: true) + onboardingView.hidesBottomBarWhenPushed = true navigationController?.pushViewController(onboardingView, animated: true) } } diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift deleted file mode 100644 index c6cb7e1b..00000000 --- a/Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift +++ /dev/null @@ -1,224 +0,0 @@ -// -// OnboardingRecommendedRoutineView.swift -// Presentation -// -// Created by 최정인 on 7/11/25. -// - -import Combine -import Domain -import Shared -import UIKit - -final class OnboardingRecommendedRoutineView: BaseViewController { - - private enum Layout { - static let horizontalMargin: CGFloat = 20 - static let mainLabelHeight: CGFloat = 60 - static let subLabelTopSpacing: CGFloat = 10 - static let subLabelHeight: CGFloat = 40 - static let routineStackViewSpacing: CGFloat = 12 - static let routineStackViewTopSpacing: CGFloat = 28 - static let routineButtonHeight: CGFloat = 84 - static let registerButtonHeight: CGFloat = 54 - static let registerButtonBottomSpacing: CGFloat = 10 - static let skipButtonLabelFontSize: CGFloat = 14 - static let skipButtonLabelLineHeight: CGFloat = 20 - static let skipButtonHeight: CGFloat = 54 - static let skipButtonBottomSpacing: CGFloat = 20 - - static var mainLabelTopSpacing: CGFloat { - let height = UIScreen.main.bounds.height - if height <= 667 { return 12 } - else { return 32 } - } - } - - private let mainLabel = UILabel() - private var subLabel = UILabel() - 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 - - override init(viewModel: OnboardingViewModel) { - cancellables = [] - super.init(viewModel: viewModel) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - 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)) - } - - override func configureAttribute() { - let mainLabelText = "당신만의 추천 루틴이\n생성되었어요!" - mainLabel.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: mainLabelText) - mainLabel.textColor = BitnagilColor.navy500 - mainLabel.numberOfLines = 2 - mainLabel.textAlignment = .left - - let subLabelText = "당신의 생활 패턴과 목표에 맞춰 구성된 맞춤 루틴이에요.\n원하는 루틴을 선택해서 가볍게 시작해보세요." - subLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: subLabelText) - subLabel.textColor = BitnagilColor.gray50 - subLabel.numberOfLines = 2 - subLabel.textAlignment = .left - - recommendedRoutineStackView.axis = .vertical - recommendedRoutineStackView.spacing = Layout.routineStackViewSpacing - - registerButton.addAction(UIAction { [weak self] _ in - self?.viewModel.action(input: .registerRecommendedRoutine) - }, for: .touchUpInside) - - skipButtonLabel.attributedText = BitnagilFont( - fontSize: Layout.skipButtonLabelFontSize, - lineHeight: Layout.skipButtonLabelLineHeight, - underline: true, - weight: .regular - ).attributedString(text: "건너뛰기") - skipButtonLabel.textColor = BitnagilColor.navy500 - - skipButton.addAction(UIAction { [weak self] _ in - self?.goToHomeView() - }, for: .touchUpInside) - } - - override func configureLayout() { - let safeArea = view.safeAreaLayoutGuide - view.backgroundColor = BitnagilColor.gray99 - - view.addSubview(mainLabel) - 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) - make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - make.top.equalTo(safeArea).offset(Layout.mainLabelTopSpacing) - make.height.equalTo(Layout.mainLabelHeight) - } - - subLabel.snp.makeConstraints { make in - make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) - make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) - make.height.equalTo(Layout.subLabelHeight) - } - - recommendedRoutineStackView.snp.makeConstraints { make in - make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) - make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - make.top.equalTo(subLabel.snp.bottom).offset(Layout.routineStackViewTopSpacing) - } - - registerButton.snp.makeConstraints { make in - make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) - make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - 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) - } - } - - override func bind() { - viewModel.output.recommendedRoutinePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] recommendedRoutines in - self?.updateRecommendedRoutines(routines: recommendedRoutines) - } - .store(in: &cancellables) - - viewModel.output.selectedRoutinePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] selectedRoutines in - self?.updateSelectedRoutines(routines: selectedRoutines) - } - .store(in: &cancellables) - - viewModel.output.nextButtonPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] canRegister in - 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) { - recommendedRoutineStackView.arrangedSubviews.forEach { view in - recommendedRoutineStackView.removeArrangedSubview(view) - view.removeFromSuperview() - } - recommendedRoutines.removeAll() - - for routine in routines { - let routineButton = OnboardingChoiceButton(onboardingChoice: routine) - routineButton.tag = routine.id - - recommendedRoutines[routine.id] = routineButton - recommendedRoutineStackView.addArrangedSubview(routineButton) - routineButton.addAction(UIAction { [weak self] _ in - self?.viewModel.action(input: .selectRoutine(routine: routine)) - }, for: .touchUpInside) - - routineButton.snp.makeConstraints { make in - make.height.equalTo(Layout.routineButtonHeight) - } - } - } - - private func updateSelectedRoutines(routines: Set) { - recommendedRoutines.forEach { routine in - if routines.contains(where: { $0.id == routine.key }) { - routine.value.updateButtonState(isChecked: true) - } else { - routine.value.updateButtonState(isChecked: false) - } - } - } - - private func goToHomeView() { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }) { - window.rootViewController = TabBarView() - } - } -} diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift index d42c3350..73ffae1a 100644 --- a/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingResultView.swift @@ -7,25 +7,24 @@ import Combine import Domain +import Shared +import SnapKit import UIKit final class OnboardingResultView: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 + static let mainLabelMinTopSpacing: CGFloat = 12 + static let mainLabelMaxTopSpacing: CGFloat = 32 static let mainLabelHeight: CGFloat = 60 static let subLabelTopSpacing: CGFloat = 10 static let subLabelHeight: CGFloat = 20 static let resultStackViewTopSpacing: CGFloat = 4 static let resultStackViewSpacing: CGFloat = 2 - static let graphicTopSpacing: CGFloat = 36 - static let graphicBotttomSpacing: CGFloat = 20 - - static var mainLabelTopSpacing: CGFloat { - let height = UIScreen.main.bounds.height - if height <= 667 { return 12 } - else { return 32 } - } + static let graphicTopSpacing: CGFloat = 80 + static let graphicWidth: CGFloat = 306 + static let graphicHeight: CGFloat = 290 } private let mainLabel = UILabel() @@ -34,10 +33,15 @@ final class OnboardingResultView: BaseViewController { private var timeResultLabel = UILabel() private var feelingResultLabel = UILabel() private var outdoorResultLabel = UILabel() - private let graphicView = UIView() - private var cancellables: Set + private let graphicView = UIImageView() - override init(viewModel: OnboardingViewModel) { + private var isLayoutConfigured: Bool = false + private var mainLabelTopConstraint: Constraint? + + private let isFromMypage: Bool + private var cancellables: Set + init(viewModel: OnboardingViewModel, isFromMypage: Bool = false) { + self.isFromMypage = isFromMypage cancellables = [] super.init(viewModel: viewModel) } @@ -54,12 +58,10 @@ final class OnboardingResultView: BaseViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - UIView.animate(withDuration: 0.5, delay: 3, options: .curveEaseInOut, animations: { + UIView.animate(withDuration: 0.5, delay: 2, options: .curveEaseInOut, animations: { self.view.alpha = 0.0 - }, completion: { [weak self] finished in - guard let self else { return } - let recommendedRoutineView = OnboardingRecommendedRoutineView(viewModel: self.viewModel) - self.navigationController?.pushViewController(recommendedRoutineView, animated: true) + }, completion: { [weak self] finshed in + self?.viewModel.action(input: .fetchOnboardingChoices) }) } @@ -70,6 +72,22 @@ final class OnboardingResultView: BaseViewController { configureNavigationBar(navigationStyle: .withPrograssBar(step: stepCount, stepCount: stepCount)) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if !isLayoutConfigured { + updateMainLabelTopSpacing() + isLayoutConfigured = true + } + } + + private func updateMainLabelTopSpacing() { + let height = view.bounds.height + let spacing: CGFloat = height <= 667 ? Layout.mainLabelMinTopSpacing : Layout.mainLabelMaxTopSpacing + + mainLabelTopConstraint?.update(offset: spacing) + } + override func configureAttribute() { let text = "이제 당신에게\n꼭 맞는 루틴을 제안해드릴게요." mainLabel.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: text) @@ -88,7 +106,7 @@ final class OnboardingResultView: BaseViewController { label.textColor = BitnagilColor.gray30 } - graphicView.backgroundColor = BitnagilColor.gray90 + graphicView.image = BitnagilGraphic.onboardingGraphic } override func configureLayout() { @@ -106,7 +124,7 @@ final class OnboardingResultView: BaseViewController { mainLabel.snp.makeConstraints { make in make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - make.top.equalTo(safeArea).offset(Layout.mainLabelTopSpacing) + mainLabelTopConstraint = make.top.equalTo(safeArea).offset(Layout.mainLabelMinTopSpacing).constraint make.height.equalTo(Layout.mainLabelHeight) } @@ -130,10 +148,10 @@ final class OnboardingResultView: BaseViewController { } graphicView.snp.makeConstraints { make in - make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) - make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) make.top.equalTo(resultStackView.snp.bottom).offset(Layout.graphicTopSpacing) - make.bottom.equalTo(safeArea).inset(Layout.graphicBotttomSpacing) + make.centerX.equalToSuperview() + make.width.equalTo(Layout.graphicWidth) + make.height.equalTo(Layout.graphicHeight) } } @@ -144,6 +162,13 @@ final class OnboardingResultView: BaseViewController { self?.updateResultLabels(results: onboardingResults) } .store(in: &cancellables) + + viewModel.output.onboardingChoicesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] onboardingChoices in + self?.goToResultRecommendedRoutineView(onboardingChoices: onboardingChoices) + } + .store(in: &cancellables) } private func updateResultLabels(results: [String]) { @@ -183,4 +208,19 @@ final class OnboardingResultView: BaseViewController { let baseText = "• \(outdoorResult)을 목표로 해볼게요!" outdoorResultLabel.attributedText = NSAttributedString.highlighted(text: baseText, highlightText: outdoorResult) } + + private func goToResultRecommendedRoutineView(onboardingChoices: [OnboardingChoiceType]) { + guard let resultRecommendedRoutineViewModel = DIContainer.shared.resolve(type: ResultRecommendedRoutineViewModel.self) + else{ fatalError("resultRecommendedRoutineViewModel 의존성이 등록되지 않았습니다.") } + + var resultRecommendedView: ResultRecommendedRoutineView + if isFromMypage { + resultRecommendedRoutineViewModel.configure(viewModelType: .mypage(onboardingChoices: onboardingChoices)) + resultRecommendedView = ResultRecommendedRoutineView(entryPoint: .mypage, viewModel: resultRecommendedRoutineViewModel) + } else { + resultRecommendedRoutineViewModel.configure(viewModelType: .onboarding(onboardingChoices: onboardingChoices)) + resultRecommendedView = ResultRecommendedRoutineView(entryPoint: .onboarding, viewModel: resultRecommendedRoutineViewModel) + } + self.navigationController?.pushViewController(resultRecommendedView, animated: true) + } } diff --git a/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift index e801cf74..93a26f99 100644 --- a/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift +++ b/Projects/Presentation/Sources/Onboarding/View/OnboardingView.swift @@ -7,12 +7,15 @@ import Combine import Domain +import SnapKit import UIKit final class OnboardingView: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 + static let mainLabelMinTopSpacing: CGFloat = 12 + static let mainLabelMaxTopSpacing: CGFloat = 32 static let mainLabelHeight: CGFloat = 60 static let subLabelTopSpacing: CGFloat = 10 static let choiceButtonHeight: CGFloat = 52 @@ -21,12 +24,6 @@ final class OnboardingView: BaseViewController { static let choiceStackViewTopSpacing: CGFloat = 28 static let nextButtonHeight: CGFloat = 54 static let nextButtonBottomSpacing: CGFloat = 20 - - static var mainLabelTopSpacing: CGFloat { - let height = UIScreen.main.bounds.height - if height <= 667 { return 12 } - else { return 32 } - } } private let onboarding: OnboardingType @@ -35,10 +32,19 @@ final class OnboardingView: BaseViewController { private let choiceStackView = UIStackView() private var choiceButtons: [OnboardingChoiceType: OnboardingChoiceButton] = [:] private let nextButton = PrimaryButton(buttonState: .disabled, buttonTitle: "다음") - private var cancellables: Set - init(viewModel: OnboardingViewModel, onboarding: OnboardingType) { + private var isLayoutConfigured: Bool = false + private var mainLabelTopConstraint: Constraint? + + private let isFromMypage: Bool + private var cancellables: Set + init( + viewModel: OnboardingViewModel, + onboarding: OnboardingType, + isFromMypage: Bool = false + ) { self.onboarding = onboarding + self.isFromMypage = isFromMypage cancellables = [] super.init(viewModel: viewModel) } @@ -60,6 +66,22 @@ final class OnboardingView: BaseViewController { self.viewModel.action(input: .fetchOnboardingChoice(onboarding: onboarding)) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if !isLayoutConfigured { + updateMainLabelTopSpacing() + isLayoutConfigured = true + } + } + + private func updateMainLabelTopSpacing() { + let height = view.bounds.height + let spacing: CGFloat = height <= 667 ? Layout.mainLabelMinTopSpacing : Layout.mainLabelMaxTopSpacing + + mainLabelTopConstraint?.update(offset: spacing) + } + override func configureAttribute() { mainLabel.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: onboarding.mainTitle) mainLabel.textColor = BitnagilColor.navy500 @@ -111,7 +133,7 @@ final class OnboardingView: BaseViewController { mainLabel.snp.makeConstraints { make in make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - make.top.equalTo(safeArea).offset(Layout.mainLabelTopSpacing) + mainLabelTopConstraint = make.top.equalTo(safeArea).offset(Layout.mainLabelMinTopSpacing).constraint make.height.equalTo(Layout.mainLabelHeight) } @@ -212,9 +234,12 @@ final class OnboardingView: BaseViewController { var nextView: UIViewController? if let nextStep { - nextView = OnboardingView(viewModel: viewModel, onboarding: nextStep) + nextView = OnboardingView( + viewModel: viewModel, + onboarding: nextStep, + isFromMypage: isFromMypage) } else { - nextView = OnboardingResultView(viewModel: viewModel) + nextView = OnboardingResultView(viewModel: viewModel, isFromMypage: isFromMypage) } guard let nextView else { return } self.navigationController?.pushViewController(nextView, animated: true) diff --git a/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift index 1e1b73a1..e14028c9 100644 --- a/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift +++ b/Projects/Presentation/Sources/Onboarding/ViewModel/OnboardingViewModel.swift @@ -13,10 +13,8 @@ final class OnboardingViewModel: ViewModel { enum Input { case selectOnboardingChoice(selectedChoice: OnboardingChoiceType) case fetchOnboardingChoice(onboarding: OnboardingType) + case fetchOnboardingChoices case makeOnboardingResult - case registerOnboarding - case selectRoutine(routine: RecommendedRoutine) - case registerRecommendedRoutine } struct Output { @@ -25,9 +23,7 @@ final class OnboardingViewModel: ViewModel { let feelingOnboardingChoicePublisher: AnyPublisher, Never> let outdoorOnboardingChoicePublisher: AnyPublisher let onboardingResultPublisher: AnyPublisher<[String], Never> - let recommendedRoutinePublisher: AnyPublisher, Never> - let selectedRoutinePublisher: AnyPublisher, Never> - let registerRoutineResultPublisher: AnyPublisher + let onboardingChoicesPublisher: AnyPublisher<[OnboardingChoiceType], Never> let nextButtonPublisher: AnyPublisher } @@ -37,23 +33,17 @@ final class OnboardingViewModel: ViewModel { private let feelingOnboardingChoiceSubject = CurrentValueSubject, Never>([]) 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 onboardingChoicesSubject = PassthroughSubject<[OnboardingChoiceType], Never>() private let nextButtonSubject = PassthroughSubject() - private let onboardingUseCase: OnboardingUseCaseProtocol - init(onboardingUseCase: OnboardingUseCaseProtocol) { - self.onboardingUseCase = onboardingUseCase + init() { self.output = Output( timeOnboardingChoicePublisher: timeOnboardingChoiceSubject.eraseToAnyPublisher(), frequencyOnboardingChoicePublisher: frequencyOnboardingChoiceSubject.eraseToAnyPublisher(), feelingOnboardingChoicePublisher: feelingOnboardingChoiceSubject.eraseToAnyPublisher(), outdoorOnboardingChoicePublisher: outdoorOnboardingChoiceSubject.eraseToAnyPublisher(), onboardingResultPublisher: onboardingResultSubject.eraseToAnyPublisher(), - recommendedRoutinePublisher: recommendedRoutineSubject.eraseToAnyPublisher(), - selectedRoutinePublisher: selectedRoutineSubject.eraseToAnyPublisher(), - registerRoutineResultPublisher: registerRoutineResultSubject.eraseToAnyPublisher(), + onboardingChoicesPublisher: onboardingChoicesSubject.eraseToAnyPublisher(), nextButtonPublisher: nextButtonSubject.eraseToAnyPublisher() ) } @@ -66,17 +56,11 @@ final class OnboardingViewModel: ViewModel { case .fetchOnboardingChoice(let onboarding): fetchChoice(onboarding: onboarding) + case .fetchOnboardingChoices: + makeOnboardingChoices() + case .makeOnboardingResult: makeOnboardingResult() - - case .registerOnboarding: - registerOnboarding() - - case .selectRoutine(let routine): - selectRoutine(routine: routine) - - case .registerRecommendedRoutine: - registerRecommendedRoutine() } } @@ -155,18 +139,13 @@ final class OnboardingViewModel: ViewModel { } } - // 다음 버튼 활성화 여부를 결정합니다. (중복 선택 온보딩, 추천 루틴 등록) - private func updateNextButtonSubject(for registerButton: Bool = false) { - var result: Bool - if registerButton { - result = !selectedRoutineSubject.value.isEmpty - } else { - result = !feelingOnboardingChoiceSubject.value.isEmpty - } + // 다음 버튼 활성화 여부를 결정합니다. (중복 선택 온보딩) + private func updateNextButtonSubject() { + let result = !feelingOnboardingChoiceSubject.value.isEmpty nextButtonSubject.send(result) } - // 온보딩 결과를 만듭니다. + // 온보딩 결과 텍스트를 만듭니다. private func makeOnboardingResult() { let feelingOnboardingChoice = feelingOnboardingChoiceSubject.value let feelingResult = feelingOnboardingChoice.compactMap { $0.resultTitle }.joined(separator: ", ") @@ -184,8 +163,8 @@ final class OnboardingViewModel: ViewModel { onboardingResultSubject.send(result) } - // 온보딩 결과를 등록하고, 그 결과를 바탕으로 추천 루틴을 받아옵니다. - private func registerOnboarding() { + // 온보딩 선택지를 통합합니다. + private func makeOnboardingChoices() { var onboardingChoices: [OnboardingChoiceType] = [] let feelingOnboarding = Array(feelingOnboardingChoiceSubject.value) @@ -201,43 +180,6 @@ final class OnboardingViewModel: ViewModel { 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)") - } - } - } - - // 추천 루틴을 선택합니다. - private func selectRoutine(routine: RecommendedRoutine) { - var selectedRoutines = selectedRoutineSubject.value - if selectedRoutines.contains(routine) { - selectedRoutines.remove(routine) - } else { - selectedRoutines.insert(routine) - } - - selectedRoutineSubject.send(selectedRoutines) - updateNextButtonSubject(for: true) - } - - // 추천 루틴을 등록합니다. - private func registerRecommendedRoutine() { - 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) - } - } + onboardingChoicesSubject.send(onboardingChoices) } } diff --git a/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineView.swift b/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineView.swift new file mode 100644 index 00000000..2ca73675 --- /dev/null +++ b/Projects/Presentation/Sources/ResultRecommendedRoutine/View/ResultRecommendedRoutineView.swift @@ -0,0 +1,361 @@ +// +// ResultRecommendedRoutineView.swift +// Presentation +// +// Created by 최정인 on 7/28/25. +// + +import Combine +import Domain +import Shared +import SnapKit +import UIKit + +final class ResultRecommendedRoutineView: BaseViewController { + + enum EntryPoint { + case onboarding + case mypage + case emotion + + var mainLabelText: String { + switch self { + case .onboarding, .mypage: + "당신만의 추천 루틴이\n생성되었어요!" + case .emotion: + "오늘 감정에 따른\n루틴을 추천드릴게요!" + } + } + + var subLabelText: String { + switch self { + case .onboarding: + "원하는 루틴을 선택해서 가볍게 시작해보세요.\n선택한 루틴은 홈화면에서 자유롭게 수정 할 수 있어요." + case .mypage: + "생활 패턴과 목표에 맞춰 다시 구성된 맞춤 루틴이에요.\n원하는 루틴을 선택해서 가볍게 시작해보세요." + case .emotion: + "오늘 당신의 감정 상태에 맞춰 구성된 맞춤 루틴이에요.\n원하는 루틴을 선택해서 가볍게 시작해보세요." + } + } + + var confirmButtonLabel: String { + switch self { + case .onboarding: "등록하기" + case .mypage: "확인" + case .emotion: "루틴 등록하기" + } + } + + var isHiddenSkipButton: Bool { + switch self { + case .onboarding, .emotion: + return false + case .mypage: + return true + } + } + + var isRoutineButtonEnabled: Bool { + switch self { + case .onboarding, .emotion: + true + case .mypage: + false + } + } + } + + private enum Layout { + static let horizontalMargin: CGFloat = 20 + static let mainLabelMinTopSpacing: CGFloat = 12 + static let mainLabelMaxTopSpacing: CGFloat = 32 + static let mainLabelHeight: CGFloat = 60 + static let subLabelTopSpacing: CGFloat = 10 + static let subLabelHeight: CGFloat = 40 + static let routineStackViewSpacing: CGFloat = 12 + static let routineStackViewTopSpacing: CGFloat = 28 + static let routineButtonHeight: CGFloat = 84 + static let confirmButtonHeight: CGFloat = 54 + static let confirmButtonBottomSpacing: CGFloat = 10 + static let skipButtonLabelFontSize: CGFloat = 14 + static let skipButtonLabelLineHeight: CGFloat = 20 + static let skipButtonHeight: CGFloat = 54 + static let skipButtonBottomSpacing: CGFloat = 20 + } + + private let mainLabel = UILabel() + private var subLabel = UILabel() + private let recommendedRoutineStackView = UIStackView() + private var recommendedRoutines: [Int: OnboardingChoiceButton] = [:] + private var confirmButton = PrimaryButton(buttonState: .disabled, buttonTitle: "등록하기") + private let skipButtonLabel = UILabel() + private let skipButton = UIButton() + + private var isLayoutConfigured: Bool = false + private var mainLabelTopConstraint: Constraint? + + private var cancellables: Set + private let entryPoint: EntryPoint + init(entryPoint: EntryPoint, viewModel: ResultRecommendedRoutineViewModel) { + self.entryPoint = entryPoint + cancellables = [] + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.action(input: .fetchResultRecommendedRoutines) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + settingNavigationItem() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if !isLayoutConfigured { + updateMainLabelTopSpacing() + isLayoutConfigured = true + } + } + + private func updateMainLabelTopSpacing() { + let height = view.bounds.height + let spacing: CGFloat = height <= 667 ? Layout.mainLabelMinTopSpacing : Layout.mainLabelMaxTopSpacing + + mainLabelTopConstraint?.update(offset: spacing) + } + + override func configureAttribute() { + let mainLabelText = entryPoint.mainLabelText + mainLabel.attributedText = BitnagilFont(style: .title2, weight: .bold).attributedString(text: mainLabelText) + mainLabel.textColor = BitnagilColor.navy500 + mainLabel.numberOfLines = 2 + mainLabel.textAlignment = .left + + let subLabelText = entryPoint.subLabelText + subLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: subLabelText) + subLabel.textColor = BitnagilColor.gray50 + subLabel.numberOfLines = 2 + subLabel.textAlignment = .left + + recommendedRoutineStackView.axis = .vertical + recommendedRoutineStackView.spacing = Layout.routineStackViewSpacing + + configureEntryPoint() + configureConfirmButton() + + skipButtonLabel.attributedText = BitnagilFont( + fontSize: Layout.skipButtonLabelFontSize, + lineHeight: Layout.skipButtonLabelLineHeight, + underline: true, + weight: .regular + ).attributedString(text: "건너뛰기") + skipButtonLabel.textColor = BitnagilColor.navy500 + + skipButton.isHidden = entryPoint.isHiddenSkipButton + configureSkipButton() + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.backgroundColor = BitnagilColor.gray99 + + view.addSubview(mainLabel) + view.addSubview(subLabel) + view.addSubview(recommendedRoutineStackView) + view.addSubview(confirmButton) + skipButton.addSubview(skipButtonLabel) + view.addSubview(skipButton) + + mainLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + mainLabelTopConstraint = make.top.equalTo(safeArea).offset(Layout.mainLabelMinTopSpacing).constraint + make.height.equalTo(Layout.mainLabelHeight) + } + + subLabel.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) + make.height.equalTo(Layout.subLabelHeight) + } + + recommendedRoutineStackView.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.top.equalTo(subLabel.snp.bottom).offset(Layout.routineStackViewTopSpacing) + } + + confirmButton.snp.makeConstraints { make in + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + if entryPoint.isHiddenSkipButton { + make.bottom.equalTo(safeArea).inset(20) + } else { + make.bottom.equalTo(skipButton.snp.top).offset(-Layout.confirmButtonBottomSpacing) + } + make.height.equalTo(Layout.confirmButtonHeight) + } + + 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) + } + } + + private func configureEntryPoint() { + switch entryPoint { + case .onboarding: + confirmButton = PrimaryButton(buttonState: .disabled, buttonTitle: entryPoint.confirmButtonLabel) + case .mypage: + confirmButton = PrimaryButton(buttonState: .default, buttonTitle: entryPoint.confirmButtonLabel) + case .emotion: + confirmButton = PrimaryButton(buttonState: .disabled, buttonTitle: entryPoint.confirmButtonLabel) + } + } + + override func bind() { + viewModel.output.resultRecommendedRoutinesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] resultRecommendedRoutines in + self?.updateRecommendedRoutineViews(routines: resultRecommendedRoutines) + } + .store(in: &cancellables) + + viewModel.output.selectedRecommendedRoutinePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] selectedRoutines in + self?.updateSelectedRoutines(routines: selectedRoutines) + } + .store(in: &cancellables) + + viewModel.output.confirmButtonPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] canGoNextView in + self?.confirmButton.updateButtonState(buttonState: canGoNextView ? .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?.goToNextView() + } else { + BitnagilLogger.log(logType: .error, message: "추천 루틴 등록 실패") + } + } + .store(in: &cancellables) + } + + // 추천 루틴 뷰들을 업데이트합니다. + private func updateRecommendedRoutineViews(routines: [RecommendedRoutine]) { + recommendedRoutineStackView.arrangedSubviews.forEach { view in + recommendedRoutineStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + recommendedRoutines.removeAll() + + for routine in routines { + let routineButton = OnboardingChoiceButton(onboardingChoice: routine) + routineButton.tag = routine.id + + recommendedRoutines[routine.id] = routineButton + recommendedRoutineStackView.addArrangedSubview(routineButton) + routineButton.addAction(UIAction { [weak self] _ in + self?.viewModel.action(input: .selectRecommendedRoutine(routine: routine)) + }, for: .touchUpInside) + routineButton.isEnabled = entryPoint.isRoutineButtonEnabled + routineButton.snp.makeConstraints { make in + make.height.equalTo(Layout.routineButtonHeight) + } + } + } + + private func updateSelectedRoutines(routines: Set) { + recommendedRoutines.forEach { routine in + if routines.contains(where: { $0.id == routine.key }) { + routine.value.updateButtonState(isChecked: true) + } else { + routine.value.updateButtonState(isChecked: false) + } + } + } + + private func settingNavigationItem() { + switch entryPoint { + case .onboarding, .mypage: + let stepCount = OnboardingType.allCases.count + 1 + configureNavigationBar(navigationStyle: .withPrograssBarWithCustomBackButton(step: stepCount, stepCount: stepCount)) + case .emotion: + configureNavigationBar(navigationStyle: .withBackButton(title: "")) + } + } + + private func configureConfirmButton() { + confirmButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + switch self.entryPoint { + case .onboarding, .emotion: + self.viewModel.action(input: .registerRecommendedRoutine) + case .mypage: + self.goToNextView() + } + }, for: .touchUpInside) + } + + private func configureSkipButton() { + skipButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + switch entryPoint { + case .emotion: + if let navigationController = self.navigationController { + let viewControllers = navigationController.viewControllers + if viewControllers.count >= 3 { + navigationController.popToViewController(viewControllers[viewControllers.count - 3], animated: false) + } + } + case .onboarding, .mypage: + goToNextView() + } + }, for: .touchUpInside) + } + + private func goToNextView() { + switch entryPoint { + case .onboarding: + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) { + window.rootViewController = TabBarView() + } + case .mypage: + if + let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }), + let tabBarView = window.rootViewController as? TabBarView { + self.navigationController?.popToRootViewController(animated: false) + tabBarView.selectedIndex = 2 + } + case .emotion: + guard let routineCreationViewModel = DIContainer.shared.resolve(type: RoutineCreationViewModel.self) + else { fatalError("routineCreationViewModel 의존성이 등록되지 않았습니다.") } + let routineCreationView = RoutineCreationView(viewModel: routineCreationViewModel) + self.navigationController?.pushViewController(routineCreationView, animated: true) + } + } +} diff --git a/Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift b/Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift new file mode 100644 index 00000000..0a17fcd8 --- /dev/null +++ b/Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift @@ -0,0 +1,145 @@ +// +// ResultRecommendedRoutineViewModel.swift +// Presentation +// +// Created by 최정인 on 7/28/25. +// + +import Combine +import Domain +import Shared + +final class ResultRecommendedRoutineViewModel: ViewModel { + + enum ResultRecommendedRoutineViewModelType { + case onboarding(onboardingChoices: [OnboardingChoiceType]) + case mypage(onboardingChoices: [OnboardingChoiceType]) + case emotion(emotion: Emotion) + } + + enum Input { + case fetchResultRecommendedRoutines + case selectRecommendedRoutine(routine: RecommendedRoutine) + case registerRecommendedRoutine + } + + struct Output { + let resultRecommendedRoutinesPublisher: AnyPublisher<[RecommendedRoutine], Never> + let selectedRecommendedRoutinePublisher: AnyPublisher, Never> + let confirmButtonPublisher: AnyPublisher + let registerRoutineResultPublisher: AnyPublisher + } + + private(set) var output: Output + private let resultRecommendedRoutinesSubject = CurrentValueSubject<[RecommendedRoutine], Never>([]) + private let selectedRecommendedRoutineSubject = CurrentValueSubject, Never>([]) + private let confirmButtonSubject = PassthroughSubject() + private let registerRoutineResultSubject = PassthroughSubject() + + private var viewModelType: ResultRecommendedRoutineViewModelType? + private let resultRecommendedRoutineUseCase: ResultRecommendedRoutineUseCaseProtocol + init(resultRecommendedRoutineUseCase: ResultRecommendedRoutineUseCaseProtocol) { + self.resultRecommendedRoutineUseCase = resultRecommendedRoutineUseCase + output = Output( + resultRecommendedRoutinesPublisher: resultRecommendedRoutinesSubject.eraseToAnyPublisher(), + selectedRecommendedRoutinePublisher: selectedRecommendedRoutineSubject.eraseToAnyPublisher(), + confirmButtonPublisher: confirmButtonSubject.eraseToAnyPublisher(), + registerRoutineResultPublisher: registerRoutineResultSubject.eraseToAnyPublisher() + ) + } + + func action(input: Input) { + switch input { + case .fetchResultRecommendedRoutines: + fetchResultRecommendedRoutines() + + case .selectRecommendedRoutine(let routine): + selectRecommendedRoutine(routine: routine) + + case .registerRecommendedRoutine: + registerRecommendedRoutine() + } + } + + // ViewModelType의 설정합니다. (setter) + func configure(viewModelType: ResultRecommendedRoutineViewModelType) { + self.viewModelType = viewModelType + } + + // 추천 루틴 결과를 불러옵니다. (viewModelType에 따른 분기 처리) + private func fetchResultRecommendedRoutines() { + Task { + do { + switch viewModelType { + case .onboarding(let onboardingChoices): + try await fetchResultRecommendedRoutines(onboardingChoices: onboardingChoices) + case .mypage(let onboardingChoices): + try await fetchResultRecommendedRoutines(onboardingChoices: onboardingChoices) + case .emotion(let emotion): + try await fetchResultRecommendedRoutines(emotion: emotion) + case nil: + fatalError("ResultRecommendedRoutineViewModel Type이 설정되지 않았습니다.") + } + } catch { + // TODO: 에러 처리 + BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + } + } + } + + // 온보딩 · 목표 선택지를 등록하고 그에 따른 추천 루틴 결과를 불러옵니다. + private func fetchResultRecommendedRoutines(onboardingChoices: [OnboardingChoiceType]) async throws { + let entities = try await resultRecommendedRoutineUseCase.fetchResultRecommendedRoutines(onboardingChoices: onboardingChoices) + let recommendedRoutines = entities.map({ $0.toRecommendedRoutine() }) + resultRecommendedRoutinesSubject.send([]) + resultRecommendedRoutinesSubject.send(recommendedRoutines) + } + + // 감정구슬을 등록하고 그에 따른 추천 루틴 결과를 불러옵니다. + private func fetchResultRecommendedRoutines(emotion: Emotion) async throws { + let entities = try await resultRecommendedRoutineUseCase.fetchResultRecommendedRoutines(emotion: emotion.emotionType) + let recommendedRoutines = entities.map({ $0.toRecommendedRoutine() }) + resultRecommendedRoutinesSubject.send([]) + resultRecommendedRoutinesSubject.send(recommendedRoutines) + } + + private func resetResultRecommendedRoutines() { + resultRecommendedRoutinesSubject.send([]) + } + + // 추천 루틴을 선택합니다. + private func selectRecommendedRoutine(routine: RecommendedRoutine) { + var selectedRoutines = selectedRecommendedRoutineSubject.value + if selectedRoutines.contains(routine) { + selectedRoutines.remove(routine) + } else { + if case .emotion = viewModelType { + selectedRoutines.removeAll() + } + selectedRoutines.insert(routine) + } + selectedRecommendedRoutineSubject.send(selectedRoutines) + updateRegisterButtonSubject() + } + + + // 등록하기 버튼 상태를 업데이트 합니다. (온보딩, 감정구슬 시에만 작동) + private func updateRegisterButtonSubject() { + let result = !selectedRecommendedRoutineSubject.value.isEmpty + confirmButtonSubject.send(result) + } + + // 추천 루틴을 등록합니다. + private func registerRecommendedRoutine() { + let selectedRoutinesId = selectedRecommendedRoutineSubject.value.map({ $0.id }) + Task { + do { + try await resultRecommendedRoutineUseCase.registerRecommendedRoutines(selectedRoutines: selectedRoutinesId) + registerRoutineResultSubject.send(true) + } catch { + BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + registerRoutineResultSubject.send(false) + } + } + } +} diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 03ed7691..48d32d8e 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -18,6 +18,15 @@ "version" : "2.24.4" } }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "2015fda791daa72c8058619545a593bf8c1dd59f", + "version" : "8.5.0" + } + }, { "identity" : "snapkit", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index f1dc9d46..32560eeb 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -5,6 +5,7 @@ let package = Package( name: "Bitnagil", dependencies: [ .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.0.0"), - .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.23.0") + .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.23.0"), + .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.0.0") ] )