diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index fcfb725a..7b70c841 100644 --- a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift +++ b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift @@ -24,5 +24,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: UserDataRepositoryProtocol.self) { _ in return UserDataRepository() } + + DIContainer.shared.register(type: RecommendedRoutineRepositoryProtocol.self) { _ in + return RecommendedRoutineRepository() + } } } diff --git a/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift b/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift index 51a59a6e..fa5ab17e 100644 --- a/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift +++ b/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift @@ -2,37 +2,47 @@ // RecommendedRoutineDTO.swift // DataSource // -// Created by 최정인 on 7/15/25. +// Created by 최정인 on 7/27/25. // import Domain -struct RecommendedRoutineListResponseDTO: Decodable { - let recommendedRoutines: [RecommendedRoutineDTO] -} - struct RecommendedRoutineDTO: Decodable { let id: Int let routineName: String let routineDescription: String - let subRoutines: [SubRoutine] + let routineLevel: String? + let subRoutines: [SubRoutineDTO] enum CodingKeys: String, CodingKey { case id = "recommendedRoutineId" case routineName = "recommendedRoutineName" - case routineDescription - case subRoutines = "recommendedSubRoutines" + case routineDescription = "recommendedRoutineDescription" + case routineLevel = "recommendedRoutineLevel" + case subRoutines = "recommendedSubRoutineSearchResult" } - func toRecommendedRoutineEntity() -> RecommendedRoutineEntity { + func toRecommendedRoutineEntity(category: String? = nil) -> RecommendedRoutineEntity { + var routineCategory: RoutineCategoryType? + if let category { + routineCategory = RoutineCategoryType(rawValue: category) + } + + var level: RoutineLevelType? + if let routineLevel { + level = RoutineLevelType(rawValue: routineLevel) + } return RecommendedRoutineEntity( id: id, title: routineName, - description: routineDescription) + description: routineDescription, + category: routineCategory, + level: level, + subRoutines: subRoutines.compactMap({ $0.toSubRoutineEntity() })) } } -struct SubRoutine: Decodable { +struct SubRoutineDTO: Decodable { let id: Int let routineName: String @@ -40,4 +50,8 @@ struct SubRoutine: Decodable { case id = "recommendedSubRoutineId" case routineName = "recommendedSubRoutineName" } + + func toSubRoutineEntity() -> SubRoutineEntity { + return SubRoutineEntity(id: id, title: routineName) + } } diff --git a/Projects/DataSource/Sources/DTO/RecommendedRoutineDictionaryResponseDTO.swift b/Projects/DataSource/Sources/DTO/RecommendedRoutineDictionaryResponseDTO.swift new file mode 100644 index 00000000..5a2d8e4b --- /dev/null +++ b/Projects/DataSource/Sources/DTO/RecommendedRoutineDictionaryResponseDTO.swift @@ -0,0 +1,10 @@ +// +// RecommendedRoutineDictionaryResponseDTO.swift +// DataSource +// +// Created by 최정인 on 7/27/25. +// + +struct RecommendedRoutineDictionaryResponseDTO: Decodable { + let recommendedRoutines: [String: [RecommendedRoutineDTO]] +} diff --git a/Projects/DataSource/Sources/DTO/RecommendedRoutineListResponseDTO.swift b/Projects/DataSource/Sources/DTO/RecommendedRoutineListResponseDTO.swift new file mode 100644 index 00000000..acc8e8fa --- /dev/null +++ b/Projects/DataSource/Sources/DTO/RecommendedRoutineListResponseDTO.swift @@ -0,0 +1,10 @@ +// +// RecommendedRoutineListResponseDTO.swift +// DataSource +// +// Created by 최정인 on 7/15/25. +// + +struct RecommendedRoutineListResponseDTO: Decodable { + let recommendedRoutines: [RecommendedRoutineDTO] +} diff --git a/Projects/DataSource/Sources/Endpoint/AuthEndpoint.swift b/Projects/DataSource/Sources/Endpoint/AuthEndpoint.swift index b22943f2..4a8db4d7 100644 --- a/Projects/DataSource/Sources/Endpoint/AuthEndpoint.swift +++ b/Projects/DataSource/Sources/Endpoint/AuthEndpoint.swift @@ -5,7 +5,6 @@ // Created by 최정인 on 6/30/25. // -import Foundation import Domain enum AuthEndpoint { diff --git a/Projects/DataSource/Sources/Endpoint/OnboardingEndpoint.swift b/Projects/DataSource/Sources/Endpoint/OnboardingEndpoint.swift index 554704da..7738f276 100644 --- a/Projects/DataSource/Sources/Endpoint/OnboardingEndpoint.swift +++ b/Projects/DataSource/Sources/Endpoint/OnboardingEndpoint.swift @@ -5,8 +5,6 @@ // Created by 최정인 on 7/15/25. // -import Foundation - enum OnboardingEndpoint { case registerOnboarding(choices: [String: String]) case registerRecommendedRoutine(selectedRoutines: [Int]) diff --git a/Projects/DataSource/Sources/Endpoint/RecommendedRoutineEndpoint.swift b/Projects/DataSource/Sources/Endpoint/RecommendedRoutineEndpoint.swift new file mode 100644 index 00000000..c2b3805e --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/RecommendedRoutineEndpoint.swift @@ -0,0 +1,46 @@ +// +// RecommendedRoutineEndpoint.swift +// DataSource +// +// Created by 최정인 on 7/27/25. +// + +enum RecommendedRoutineEndpoint { + case fetchRecommendedRoutines +} + +extension RecommendedRoutineEndpoint: Endpoint { + var baseURL: String { + return AppProperties.baseURL + "/api/v1/recommend-routines" + } + + var path: String { + switch self { + case .fetchRecommendedRoutines: baseURL + } + } + + var method: HTTPMethod { + return .get + } + + var headers: [String : String] { + let headers: [String: String] = [ + "Content-Type": "application/json", + "accept": "*/*" + ] + return headers + } + + var queryParameters: [String : String] { + return [:] + } + + var bodyParameters: [String : Any] { + return [:] + } + + var isAuthorized: Bool { + return true + } +} diff --git a/Projects/DataSource/Sources/Persistence/KeychainStorage.swift b/Projects/DataSource/Sources/Persistence/KeychainStorage.swift index 18c27ada..356667cc 100644 --- a/Projects/DataSource/Sources/Persistence/KeychainStorage.swift +++ b/Projects/DataSource/Sources/Persistence/KeychainStorage.swift @@ -61,7 +61,7 @@ final class KeychainStorage { } private func baseQuery(for key: String) -> [String: Any] { - var query: [String: Any] = [ + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key diff --git a/Projects/DataSource/Sources/Repository/AuthRepository.swift b/Projects/DataSource/Sources/Repository/AuthRepository.swift index e55a64e3..949d8026 100644 --- a/Projects/DataSource/Sources/Repository/AuthRepository.swift +++ b/Projects/DataSource/Sources/Repository/AuthRepository.swift @@ -5,11 +5,11 @@ // Created by 최정인 on 6/30/25. // -import Foundation import Domain -import Shared -import KakaoSDKUser +import Foundation import KakaoSDKAuth +import KakaoSDKUser +import Shared final class AuthRepository: AuthRepositoryProtocol { private let networkService = NetworkService.shared diff --git a/Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift b/Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift new file mode 100644 index 00000000..94013429 --- /dev/null +++ b/Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift @@ -0,0 +1,26 @@ +// +// RecommendedRoutineRepository.swift +// DataSource +// +// Created by 최정인 on 7/27/25. +// + +import Domain + +final class RecommendedRoutineRepository: RecommendedRoutineRepositoryProtocol { + private let networkService = NetworkService.shared + + func fetchRecommendedRoutines() async throws -> [RecommendedRoutineEntity] { + let endpoint = RecommendedRoutineEndpoint.fetchRecommendedRoutines + guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineDictionaryResponseDTO.self) + else { return [] } + + var entities: [RecommendedRoutineEntity] = [] + for (category, recommendedRoutines) in response.recommendedRoutines { + let recommendedRoutineEntity = recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity(category: category) }) + entities.append(contentsOf: recommendedRoutineEntity) + } + + return entities + } +} diff --git a/Projects/Domain/Sources/DomainDependencyAssembler.swift b/Projects/Domain/Sources/DomainDependencyAssembler.swift index 063799bf..f20a954a 100644 --- a/Projects/Domain/Sources/DomainDependencyAssembler.swift +++ b/Projects/Domain/Sources/DomainDependencyAssembler.swift @@ -38,5 +38,12 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { 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) + } } } diff --git a/Projects/Domain/Sources/Entity/Enum/RoutineCategoryType.swift b/Projects/Domain/Sources/Entity/Enum/RoutineCategoryType.swift new file mode 100644 index 00000000..c9f1cd19 --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/RoutineCategoryType.swift @@ -0,0 +1,16 @@ +// +// RoutineCategoryType.swift +// Domain +// +// Created by 최정인 on 7/27/25. +// + +public enum RoutineCategoryType: String, CaseIterable { + case recommendation = "PERSONALIZED" + case outdoor = "OUTING" + case outdoorReport = "OUTING_REPORT" + case wakeup = "WAKE_UP" + case connection = "CONNECT" + case rest = "REST" + case growth = "GROW" +} diff --git a/Projects/Domain/Sources/Entity/Enum/RoutineLevelType.swift b/Projects/Domain/Sources/Entity/Enum/RoutineLevelType.swift new file mode 100644 index 00000000..4a21fc0c --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/RoutineLevelType.swift @@ -0,0 +1,12 @@ +// +// RoutineLevelType.swift +// Domain +// +// Created by 최정인 on 7/27/25. +// + +public enum RoutineLevelType: String, CaseIterable { + case easy = "LEVEL1" + case normal = "LEVEL2" + case hard = "LEVEL3" +} diff --git a/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift b/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift index db04a334..83ad0c24 100644 --- a/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift +++ b/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift @@ -9,14 +9,33 @@ public struct RecommendedRoutineEntity { public let id: Int public let title: String public let description: String + public let category: RoutineCategoryType? + public let level: RoutineLevelType? + public let subRoutines: [SubRoutineEntity] public init( id: Int, title: String, - description: String + description: String, + category: RoutineCategoryType?, + level: RoutineLevelType?, + subRoutines: [SubRoutineEntity] ) { self.id = id self.title = title self.description = description + self.category = category + self.level = level + self.subRoutines = subRoutines + } +} + +public struct SubRoutineEntity { + public let id: Int + public let title: String + + public init(id: Int, title: String) { + self.id = id + self.title = title } } diff --git a/Projects/Domain/Sources/Protocol/Repository/RecommendedRoutineRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/RecommendedRoutineRepositoryProtocol.swift new file mode 100644 index 00000000..ad43183d --- /dev/null +++ b/Projects/Domain/Sources/Protocol/Repository/RecommendedRoutineRepositoryProtocol.swift @@ -0,0 +1,13 @@ +// +// RecommendedRoutineRepositoryProtocol.swift +// Domain +// +// Created by 최정인 on 7/27/25. +// + +// 추천 루틴에 관련된 데이터를 가져오는 Repository +public protocol RecommendedRoutineRepositoryProtocol { + /// 추천 루틴 데이터를 가져옵니다. + /// - Returns: 추천 루틴 목록 + func fetchRecommendedRoutines() async throws -> [RecommendedRoutineEntity] +} diff --git a/Projects/Domain/Sources/Protocol/UseCase/RecommendedRoutineUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/RecommendedRoutineUseCaseProtocol.swift new file mode 100644 index 00000000..52f2abe2 --- /dev/null +++ b/Projects/Domain/Sources/Protocol/UseCase/RecommendedRoutineUseCaseProtocol.swift @@ -0,0 +1,12 @@ +// +// RecommendedRoutineUseCaseProtocol.swift +// Domain +// +// Created by 최정인 on 7/27/25. +// + +public protocol RecommendedRoutineUseCaseProtocol { + /// 추천 루틴 데이터를 가져옵니다. + /// - Returns: 추천 루틴 목록 + func fetchRecommendedRoutines() async throws -> [RecommendedRoutineEntity] +} diff --git a/Projects/Domain/Sources/UseCase/RecommendedRoutine/RecommendedRoutineUseCase.swift b/Projects/Domain/Sources/UseCase/RecommendedRoutine/RecommendedRoutineUseCase.swift new file mode 100644 index 00000000..b3a37d9a --- /dev/null +++ b/Projects/Domain/Sources/UseCase/RecommendedRoutine/RecommendedRoutineUseCase.swift @@ -0,0 +1,19 @@ +// +// RecommendedRoutineUseCase.swift +// Domain +// +// Created by 최정인 on 7/27/25. +// + +public final class RecommendedRoutineUseCase: RecommendedRoutineUseCaseProtocol { + private let recommendedRoutineRepository: RecommendedRoutineRepositoryProtocol + + public init(recommendedRoutineRepository: RecommendedRoutineRepositoryProtocol) { + self.recommendedRoutineRepository = recommendedRoutineRepository + } + + public func fetchRecommendedRoutines() async throws -> [RecommendedRoutineEntity] { + let recommendedRoutines = try await recommendedRoutineRepository.fetchRecommendedRoutines() + return recommendedRoutines + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/Contents.json new file mode 100644 index 00000000..5ac91cfb --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "add_routine_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "add_routine_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "add_routine_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon.png b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon.png new file mode 100644 index 00000000..52f25ed0 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon@2x.png new file mode 100644 index 00000000..62396294 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon@3x.png new file mode 100644 index 00000000..7d80e332 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/add_routine_icon.imageset/add_routine_icon@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/FloatingButton.swift b/Projects/Presentation/Sources/Common/Component/FloatingButton.swift new file mode 100644 index 00000000..918e67cd --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/FloatingButton.swift @@ -0,0 +1,57 @@ +// +// FloatingButton.swift +// Presentation +// +// Created by 최정인 on 7/28/25. +// + +import SnapKit +import UIKit + +final class FloatingButton: UIButton { + + private enum Layout { + static let floatingButtonHeight: CGFloat = 52 + static let plusIconSize: CGFloat = 15 + } + + private let plusIcon = UIImageView() + private var isToggled: Bool = false + + init() { + super.init(frame: .zero) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + backgroundColor = BitnagilColor.navy500 + layer.masksToBounds = true + layer.cornerRadius = Layout.floatingButtonHeight / 2 + + plusIcon.image = BitnagilIcon.plusIcon + plusIcon.tintColor = BitnagilColor.gray99 + } + + private func configureLayout() { + addSubview(plusIcon) + + plusIcon.snp.makeConstraints { make in + make.size.equalTo(Layout.plusIconSize) + make.center.equalToSuperview() + } + } + + func toggle() { + isToggled.toggle() + + let angle: CGFloat = isToggled ? -.pi / 4 : 0 + UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) { + self.plusIcon.transform = CGAffineTransform(rotationAngle: angle) + } + } +} diff --git a/Projects/Presentation/Sources/Common/Component/FloatingMenuView.swift b/Projects/Presentation/Sources/Common/Component/FloatingMenuView.swift new file mode 100644 index 00000000..bfdc33b3 --- /dev/null +++ b/Projects/Presentation/Sources/Common/Component/FloatingMenuView.swift @@ -0,0 +1,87 @@ +// +// FloatingMenu.swift +// Presentation +// +// Created by 최정인 on 7/28/25. +// + +import SnapKit +import UIKit + +protocol FloatingMenuViewDelegate: AnyObject { + func floatingMenuDidTapRegisterRoutineButton(_ sender: FloatingMenuView) +} + +final class FloatingMenuView: UIView { + + private enum Layout { + static let registerRoutineButtonHeight: CGFloat = 22 + static let registerRoutineButtonWidth: CGFloat = 98 + static let registerRoutineIconSize: CGFloat = 24 + static let registerRoutineLabelLeadingSpacing: CGFloat = 16 + static let registerRoutineLabelHeight: CGFloat = 20 + } + + private let containerView = UIView() + private let registerRoutineButton = UIButton() + private let registerRoutineIconView = UIImageView() + private let registerRoutineLabel = UILabel() + weak var delegate: FloatingMenuViewDelegate? + + init() { + super.init(frame: .zero) + configureAttribute() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureAttribute() { + containerView.backgroundColor = .white + containerView.layer.masksToBounds = true + containerView.layer.cornerRadius = 12 + + registerRoutineIconView.image = BitnagilIcon.addRoutineIcon + + registerRoutineLabel.text = "루틴 등록" + registerRoutineLabel.font = BitnagilFont(style: .subtitle1, weight: .medium).font + registerRoutineLabel.textColor = BitnagilColor.navy500 + + registerRoutineButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.floatingMenuDidTapRegisterRoutineButton(self) + }, for: .touchUpInside) + } + + private func configureLayout() { + addSubview(containerView) + containerView.addSubview(registerRoutineButton) + [registerRoutineIconView, registerRoutineLabel].forEach { + registerRoutineButton.addSubview($0) + } + + containerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + registerRoutineButton.snp.makeConstraints { make in + make.center.equalToSuperview() + make.height.equalTo(Layout.registerRoutineButtonHeight) + make.width.equalTo(Layout.registerRoutineButtonWidth) + } + + registerRoutineIconView.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview() + make.size.equalTo(Layout.registerRoutineIconSize) + } + + registerRoutineLabel.snp.makeConstraints { make in + make.leading.equalTo(registerRoutineIconView.snp.trailing).offset(Layout.registerRoutineLabelLeadingSpacing) + make.centerY.equalToSuperview() + make.height.equalTo(Layout.registerRoutineLabelHeight) + } + } +} diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 3930cbfe..789b7856 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -23,6 +23,7 @@ enum BitnagilIcon { static let ellipsisIcon = UIImage(named: "ellipsis_icon", in: bundle, with: nil) static let informationIcon = UIImage(named: "information_icon", in: bundle, with: nil) static let sortIcon = UIImage(named: "sort_icon", in: bundle, with: nil) + static let addRoutineIcon = UIImage(named: "add_routine_icon", in: bundle, with: nil) // MARK: - Tab Bar Icons static let homeFillIcon = UIImage(named: "home_fill_icon", in: bundle, with: nil) diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index d729f992..1d6d1c1c 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -17,7 +17,7 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { public func assemble() { preAssembler.assemble() - + DIContainer.shared.register(type: HomeViewModel.self) { _ in return HomeViewModel() } @@ -36,8 +36,11 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return OnboardingViewModel(onboardingUseCase: onboardingUseCase) } - DIContainer.shared.register(type: RecommendedRoutineViewModel.self) { _ in - return RecommendedRoutineViewModel() + DIContainer.shared.register(type: RecommendedRoutineViewModel.self) { container in + guard let recommendedRoutineUseCase = container.resolve(type: RecommendedRoutineUseCaseProtocol.self) + else { fatalError("recommendedRoutineUseCase 의존성이 등록되지 않았습니다.") } + + return RecommendedRoutineViewModel(recommendedRoutineUseCase: recommendedRoutineUseCase) } DIContainer.shared.register(type: MypageViewModel.self) { container in @@ -50,5 +53,9 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: EmotionRegisterViewModel.self) { _ in return EmotionRegisterViewModel() } + + DIContainer.shared.register(type: RoutineCreationViewModel.self) { _ in + return RoutineCreationViewModel() + } } } diff --git a/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift index 3dc72106..2b846e2c 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift @@ -12,17 +12,20 @@ public struct RecommendedRoutine: OnboardingChoiceProtocol, Hashable { let mainTitle: String let subTitle: String? let routineCategory: RoutineCategoryType + let routineLevel: RoutineLevelType init( id: Int, mainTitle: String, subTitle: String?, - routineCategory: RoutineCategoryType = .recommendation + routineCategory: RoutineCategoryType, + routineLevel: RoutineLevelType ) { self.id = id self.mainTitle = mainTitle self.subTitle = subTitle self.routineCategory = routineCategory + self.routineLevel = routineLevel } } @@ -31,6 +34,9 @@ extension RecommendedRoutineEntity { return RecommendedRoutine( id: id, mainTitle: title, - subTitle: description) + subTitle: description, + routineCategory: category ?? .recommendation, + routineLevel: level ?? .easy + ) } } diff --git a/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineCategoryType.swift b/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineCategoryType.swift index 561306cc..b1afcbf7 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineCategoryType.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineCategoryType.swift @@ -5,22 +5,18 @@ // Created by 최정인 on 7/12/25. // -enum RoutineCategoryType: CaseIterable { - case recommendation - case outdoor - case wakeup - case connection - case rest - case growth +import Domain +extension RoutineCategoryType { var id: Int { switch self { case .recommendation: 1 case .outdoor: 2 - case .wakeup: 3 - case .connection: 4 - case .rest: 5 - case .growth: 6 + case .outdoorReport: 3 + case .wakeup: 4 + case .connection: 5 + case .rest: 6 + case .growth: 7 } } @@ -28,6 +24,7 @@ enum RoutineCategoryType: CaseIterable { switch self { case .recommendation: "맞춤 추천" case .outdoor: "나가봐요" + case .outdoorReport: "나가봐요_제보" case .wakeup: "일어나요" case .connection: "연결해요" case .rest: "쉬어가요" diff --git a/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineLevelType.swift b/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineLevelType.swift index 1ede61c0..0080bfe4 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineLevelType.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/Model/RoutineLevelType.swift @@ -5,11 +5,9 @@ // Created by 최정인 on 7/17/25. // -enum RoutineLevelType: SelectableItem, CaseIterable { - case easy - case normal - case hard +import Domain +extension RoutineLevelType: SelectableItem { var id: Int { switch self { case .easy: 1 diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RecommendedRoutineCardView.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RecommendedRoutineCardView.swift index b2e569d2..08f9c07c 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RecommendedRoutineCardView.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RecommendedRoutineCardView.swift @@ -84,6 +84,7 @@ final class RecommendedRoutineCardView: UIView { labelStackView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalTo(plusButton.snp.leading) make.centerY.equalToSuperview() } diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RegisterEmotionButton.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RegisterEmotionButton.swift index b0a7ea56..24118571 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RegisterEmotionButton.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RegisterEmotionButton.swift @@ -13,7 +13,7 @@ final class RegisterEmotionButton: UIButton { private enum Layout { static let borderWith: CGFloat = 1 static let cornerRadius: CGFloat = 12 - static let plusIconImageSize: CGFloat = 8 + static let plusIconImageSize: CGFloat = 10 static let plusIconSize: CGFloat = 20 static let plusIconLeadingSpacing: CGFloat = 24 static let buttonLabelLeadingSpacing: CGFloat = 10 diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryButton.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryButton.swift index 9961c5f5..a7b30b25 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryButton.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryButton.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/12/25. // +import Domain import SnapKit import UIKit diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryView.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryView.swift index 56527e6f..95ffd787 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryView.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineCategoryView.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/12/25. // +import Domain import UIKit protocol RoutineCategoryViewDelegate: AnyObject { @@ -23,6 +24,7 @@ final class RoutineCategoryView: UIView { private let scrollView = UIScrollView() private let buttonStackView = UIStackView() private var categoryButtons: [RoutineCategoryType: RoutineCategoryButton] = [:] + private let routineCategories = RoutineCategoryType.allCases.filter({ $0 != .outdoorReport }).sorted(by: { $0.id < $1.id }) weak var delegate: RoutineCategoryViewDelegate? override init(frame: CGRect) { @@ -41,7 +43,7 @@ final class RoutineCategoryView: UIView { buttonStackView.axis = .horizontal buttonStackView.spacing = Layout.stackViewSpacing - RoutineCategoryType.allCases.sorted(by: { $0.id < $1.id }).forEach { type in + routineCategories.forEach { type in let button = RoutineCategoryButton(category: type) button.addAction(UIAction { [weak self] _ in guard let self else { return } @@ -60,7 +62,7 @@ final class RoutineCategoryView: UIView { make.edges.equalToSuperview() } - RoutineCategoryType.allCases.sorted(by: { $0.id < $1.id }).forEach { type in + routineCategories.forEach { type in guard let button = categoryButtons[type] else { return } buttonStackView.addArrangedSubview(button) button.snp.makeConstraints { make in diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineLevelButton.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineLevelButton.swift index b11d05b0..b34e3023 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineLevelButton.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/Component/RoutineLevelButton.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/12/25. // +import Domain import UIKit final class RoutineLevelButton: UIButton { diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineView.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineView.swift index fb8fde67..49c353dc 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineView.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineView.swift @@ -6,6 +6,7 @@ // import Combine +import Domain import Shared import SnapKit import UIKit @@ -26,6 +27,11 @@ final class RecommendedRoutineView: BaseViewController(items: RoutineLevelType.allCases.sorted(by: { $0.id < $1.id })) + private let recommendedRoutineScrollView = UIScrollView() private let recommendedRoutineStackView = UIStackView() private var recommendedRoutineCards: [Int: RecommendedRoutineCardView] = [:] private let registerEmotionButton = RegisterEmotionButton() - private var cancellables: Set + private var isShowingFloatingMenu: Bool = false + private let dimmedView = UIView() + private let floatingButton = FloatingButton() + private let floatingMenu = FloatingMenuView() + + private var cancellables: Set public override init(viewModel: RecommendedRoutineViewModel) { cancellables = [] super.init(viewModel: viewModel) @@ -50,7 +62,7 @@ final class RecommendedRoutineView: BaseViewController(.recommendation) private let selectedRoutineLevelSubject = CurrentValueSubject(nil) private let recommendedRoutineSubject = CurrentValueSubject<[RecommendedRoutine], Never>([]) - init() { + private let recommendedRoutineUseCase: RecommendedRoutineUseCaseProtocol + init(recommendedRoutineUseCase: RecommendedRoutineUseCaseProtocol) { + self.recommendedRoutineUseCase = recommendedRoutineUseCase self.output = Output( selectedCategoryPublisher: selectedCategorySubject.eraseToAnyPublisher(), selectedRoutineLevelPublisher: selectedRoutineLevelSubject.eraseToAnyPublisher(), @@ -35,90 +40,61 @@ final class RecommendedRoutineViewModel: ViewModel { func action(input: Input) { switch input { + case .fetchRecommendedRoutines: + fetchRecommendedRoutines() + case .selectCategory(let selectedCategory): selectCategory(selectedCategory: selectedCategory) case .selectLevel(let selectedLevel): selectLevel(selectedLevel: selectedLevel) + } + } - case .fetchRecommendedRoutines(let selectedCategory): - fetchRecommendedRoutines(selectedCategory: selectedCategory) + // 전체 추천 루틴을 조회합니다. + private func fetchRecommendedRoutines() { + Task { + do { + let recommendedRoutineEntities = try await recommendedRoutineUseCase.fetchRecommendedRoutines() + let fetchedRecommendedRoutines = recommendedRoutineEntities.compactMap({ $0.toRecommendedRoutine() }) + recommendedRoutines = fetchedRecommendedRoutines + + let currentCategory = selectedCategorySubject.value + filterRecommendedRoutines(category: currentCategory, level: nil) + } catch { + // TODO: 에러 토스트 메시지 보여주기 + BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + } } } + // 루틴 카테고리를 선택합니다. private func selectCategory(selectedCategory: RoutineCategoryType) { let currentCategory = selectedCategorySubject.value if currentCategory != selectedCategory { selectedCategorySubject.send(selectedCategory) - fetchRecommendedRoutines(selectedCategory: selectedCategory) + + let currentLevel = selectedRoutineLevelSubject.value + filterRecommendedRoutines(category: selectedCategory, level: currentLevel) } } + // 루틴 난이도를 선택합니다. private func selectLevel(selectedLevel: RoutineLevelType?) { selectedRoutineLevelSubject.send(selectedLevel) - // TODO: 현재 보여주고 있는 추천 루틴 난이도 필터링 해야 함 + + let currentCategory = selectedCategorySubject.value + filterRecommendedRoutines(category: currentCategory, level: selectedLevel) } - private func fetchRecommendedRoutines(selectedCategory: RoutineCategoryType) { - var recommendedRoutines: [RecommendedRoutine] = [] - switch selectedCategory { - case .recommendation: - recommendedRoutines = recommendationRoutines - case .outdoor: - recommendedRoutines = outdoorRoutines - case .wakeup: - recommendedRoutines = wakeupRoutines - case .connection: - recommendedRoutines = connectionRoutines - case .rest: - recommendedRoutines = restRoutines - case .growth: - recommendedRoutines = growthRoutines + // 추천 루틴을 필터링합니다. + private func filterRecommendedRoutines(category: RoutineCategoryType, level: RoutineLevelType?) { + let filteredByCategory = recommendedRoutines.filter({ $0.routineCategory == category }) + if let level { + let filteredByLevel = filteredByCategory.filter({ $0.routineLevel == level }) + recommendedRoutineSubject.send(filteredByLevel) + } else { + recommendedRoutineSubject.send(filteredByCategory) } - recommendedRoutineSubject.send(recommendedRoutines) } - - - // TODO: Dummy Data를 지우세요 - private let recommendationRoutines: [RecommendedRoutine] = [ - RecommendedRoutine(id: 1, mainTitle: "쓰레기 버리러 나가기", subTitle: "간단한 외출도 의미있는 변화예요."), - RecommendedRoutine(id: 2, mainTitle: "산책하며 노란 물건 찾아보기", subTitle: "가까운 공원까지만 나가도 상쾌해져요."), - RecommendedRoutine(id: 3, mainTitle: "밤 산책하며 노후 가로등 찾기", subTitle: "빛이 희미한 가로등이 있다면 제보해봐요."), - RecommendedRoutine(id: 4, mainTitle: "산책하며 고장난 표지판 찾기", subTitle: "훼손된 표지판을 제보해봐요.") - ] - - private let outdoorRoutines: [RecommendedRoutine] = [ - RecommendedRoutine(id: 1, mainTitle: "나가봐요", subTitle: "나가라꼬!!!", routineCategory: .outdoor), - RecommendedRoutine(id: 2, mainTitle: "나가봐요", subTitle: "나가라꼬!!!", routineCategory: .outdoor), - RecommendedRoutine(id: 3, mainTitle: "나가봐요", subTitle: "나가라꼬!!!", routineCategory: .outdoor), - RecommendedRoutine(id: 4, mainTitle: "나가봐요", subTitle: "나가라꼬!!!", routineCategory: .outdoor) - ] - - private let wakeupRoutines: [RecommendedRoutine] = [ - RecommendedRoutine(id: 1, mainTitle: "일어나요", subTitle: "일어나라꼬!!!", routineCategory: .wakeup), - RecommendedRoutine(id: 2, mainTitle: "일어나요", subTitle: "일어나라꼬!!!", routineCategory: .wakeup) - ] - - private let connectionRoutines: [RecommendedRoutine] = [ - RecommendedRoutine(id: 1, mainTitle: "연결해요", subTitle: "연결하라꼬!!!", routineCategory: .connection) - ] - - private let restRoutines: [RecommendedRoutine] = [ - RecommendedRoutine(id: 1, mainTitle: "쉬어가요", subTitle: "쉬어갑시다아 ~~~ 아아아 ~~", routineCategory: .rest) - ] - - private let growthRoutines: [RecommendedRoutine] = [ - RecommendedRoutine(id: 1, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 2, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 3, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 4, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 5, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 6, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 7, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 8, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 9, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 10, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 11, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - RecommendedRoutine(id: 12, mainTitle: "성장해요", subTitle: "성.장.했.다", routineCategory: .growth), - ] }