diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 00000000..79f53454 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 9221b9bb..cefcc878 100644 --- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,93 +1,9 @@ { "images" : [ { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "idiom" : "ios-marketing", - "scale" : "1x", + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", "size" : "1024x1024" } ], diff --git a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift index 153c99b8..92f77057 100644 --- a/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift +++ b/Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift @@ -32,5 +32,9 @@ public struct DataSourceDependencyAssembler: DependencyAssemblerProtocol { DIContainer.shared.register(type: EmotionRepositoryProtocol.self) { _ in return EmotionRepository() } + + DIContainer.shared.register(type: RoutineRepositoryProtocol.self) { _ in + return RoutineRepository() + } } } diff --git a/Projects/DataSource/Sources/DTO/EmotionResponseDTO.swift b/Projects/DataSource/Sources/DTO/EmotionResponseDTO.swift index 73be1811..ff3414ea 100644 --- a/Projects/DataSource/Sources/DTO/EmotionResponseDTO.swift +++ b/Projects/DataSource/Sources/DTO/EmotionResponseDTO.swift @@ -9,9 +9,9 @@ import Domain import Foundation struct EmotionResponseDTO: Decodable { - let type: String - let name: String - let imageUrl: String + let type: String? + let name: String? + let imageUrl: String? enum CodingKeys: String, CodingKey { case type = "emotionMarbleType" @@ -21,7 +21,13 @@ struct EmotionResponseDTO: Decodable { } extension EmotionResponseDTO { - func toEmotionEntity() -> EmotionEntity { + func toEmotionEntity() -> EmotionEntity? { + guard + let type, + let name, + let imageUrl + else { return nil } + return EmotionEntity( emotionType: type, emotionName: name, diff --git a/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift b/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift index fa5ab17e..b75c077a 100644 --- a/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift +++ b/Projects/DataSource/Sources/DTO/RecommendedRoutineDTO.swift @@ -12,7 +12,7 @@ struct RecommendedRoutineDTO: Decodable { let routineName: String let routineDescription: String let routineLevel: String? - let subRoutines: [SubRoutineDTO] + let subRoutines: [RecommendedSubRoutineDTO] enum CodingKeys: String, CodingKey { case id = "recommendedRoutineId" @@ -21,7 +21,9 @@ struct RecommendedRoutineDTO: Decodable { case routineLevel = "recommendedRoutineLevel" case subRoutines = "recommendedSubRoutineSearchResult" } +} +extension RecommendedRoutineDTO { func toRecommendedRoutineEntity(category: String? = nil) -> RecommendedRoutineEntity { var routineCategory: RoutineCategoryType? if let category { @@ -38,20 +40,6 @@ struct RecommendedRoutineDTO: Decodable { description: routineDescription, category: routineCategory, level: level, - subRoutines: subRoutines.compactMap({ $0.toSubRoutineEntity() })) - } -} - -struct SubRoutineDTO: Decodable { - let id: Int - let routineName: String - - enum CodingKeys: String, CodingKey { - case id = "recommendedSubRoutineId" - case routineName = "recommendedSubRoutineName" - } - - func toSubRoutineEntity() -> SubRoutineEntity { - return SubRoutineEntity(id: id, title: routineName) + subRoutines: subRoutines.compactMap({ $0.toRecommendedSubRoutineEntity() })) } } diff --git a/Projects/DataSource/Sources/DTO/RecommendedSubRoutineDTO.swift b/Projects/DataSource/Sources/DTO/RecommendedSubRoutineDTO.swift new file mode 100644 index 00000000..ff0f2981 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/RecommendedSubRoutineDTO.swift @@ -0,0 +1,24 @@ +// +// RecommendedSubRoutineDTO.swift +// DataSource +// +// Created by 최정인 on 7/30/25. +// + +import Domain + +struct RecommendedSubRoutineDTO: Decodable { + let id: Int + let routineName: String + + enum CodingKeys: String, CodingKey { + case id = "recommendedSubRoutineId" + case routineName = "recommendedSubRoutineName" + } +} + +extension RecommendedSubRoutineDTO { + func toRecommendedSubRoutineEntity() -> RecommendedSubRoutineEntity { + return RecommendedSubRoutineEntity(id: id, title: routineName) + } +} diff --git a/Projects/DataSource/Sources/DTO/RoutineResponseDTO.swift b/Projects/DataSource/Sources/DTO/RoutineResponseDTO.swift new file mode 100644 index 00000000..ab867295 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/RoutineResponseDTO.swift @@ -0,0 +1,41 @@ +// +// RoutineResponseDTO.swift +// DataSource +// +// Created by 최정인 on 7/30/25. +// + +import Domain + +struct RoutineDictionaryDTO: Decodable { + let routines: [String: [RoutineResponseDTO]] +} + +struct RoutineResponseDTO: Decodable { + let routineId: String + let historySeq: Int + let routineName: String + let repeatDay: [String]? + let executionTime: String + let subRoutineSearchResultDto: [SubRoutineResponseDTO] + let modifiedYn: Bool + let routineCompletionId: Int? + let completeYn: Bool + let routineType: String +} + +extension RoutineResponseDTO { + func toRoutineEntity() -> RoutineEntity { + return RoutineEntity( + routineId: routineId, + historySeq: historySeq, + routineName: routineName, + repeatDay: repeatDay, + executionTime: executionTime, + subRoutineSearchResultDto: subRoutineSearchResultDto.map({ $0.toSubRoutineEntity() }), + modifiedYn: modifiedYn, + routineCompletionId: routineCompletionId, + completeYn: completeYn, + routineType: routineType) + } +} diff --git a/Projects/DataSource/Sources/DTO/SubRoutineResponseDTO.swift b/Projects/DataSource/Sources/DTO/SubRoutineResponseDTO.swift new file mode 100644 index 00000000..ca8da61c --- /dev/null +++ b/Projects/DataSource/Sources/DTO/SubRoutineResponseDTO.swift @@ -0,0 +1,33 @@ +// +// SubRoutineResponseDTO.swift +// DataSource +// +// Created by 최정인 on 7/30/25. +// + +import Domain + +struct SubRoutineResponseDTO: Decodable { + let subRoutineId: String + let historySeq: Int + let subRoutineName: String + let modifiedYn: Bool + let sortOrder: Int + let routineCompletionId: Int? + let completeYn: Bool + let routineType: String +} + +extension SubRoutineResponseDTO { + func toSubRoutineEntity() -> SubRoutineEntity { + return SubRoutineEntity( + subRoutineId: subRoutineId, + historySeq: historySeq, + subRoutineName: subRoutineName, + modifiedYn: modifiedYn, + sortOrder: sortOrder, + routineCompletionId: routineCompletionId, + completeYn: completeYn, + routineType: routineType) + } +} diff --git a/Projects/DataSource/Sources/DTO/UserDataResponseDTO.swift b/Projects/DataSource/Sources/DTO/UserDataResponseDTO.swift new file mode 100644 index 00000000..31bfd2e3 --- /dev/null +++ b/Projects/DataSource/Sources/DTO/UserDataResponseDTO.swift @@ -0,0 +1,10 @@ +// +// UserDataResponseDTO.swift +// DataSource +// +// Created by 최정인 on 7/30/25. +// + +struct UserDataResponseDTO: Decodable { + let nickname: String +} diff --git a/Projects/DataSource/Sources/Endpoint/EmotionEndpoint.swift b/Projects/DataSource/Sources/Endpoint/EmotionEndpoint.swift index ac56ae13..dfce98d7 100644 --- a/Projects/DataSource/Sources/Endpoint/EmotionEndpoint.swift +++ b/Projects/DataSource/Sources/Endpoint/EmotionEndpoint.swift @@ -7,6 +7,7 @@ enum EmotionEndpoint { case fetchEmotions + case fetchEmotion(date: String) case registerEmotion(emotion: String) } @@ -16,12 +17,20 @@ extension EmotionEndpoint: Endpoint { } var path: String { - return baseURL + switch self { + case .fetchEmotions: + return baseURL + case .fetchEmotion(let date): + return baseURL + "/\(date)" + case .registerEmotion(let emotion): + return baseURL + } } var method: HTTPMethod { switch self { case .fetchEmotions: .get + case .fetchEmotion: .get case .registerEmotion: .post } } @@ -42,6 +51,8 @@ extension EmotionEndpoint: Endpoint { switch self { case .fetchEmotions: return [:] + case .fetchEmotion: + return [:] case .registerEmotion(let emotion): return ["emotionMarbleType": emotion] } diff --git a/Projects/DataSource/Sources/Endpoint/RoutineEndpoint.swift b/Projects/DataSource/Sources/Endpoint/RoutineEndpoint.swift new file mode 100644 index 00000000..5896af7c --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/RoutineEndpoint.swift @@ -0,0 +1,53 @@ +// +// RoutineEndpoint.swift +// DataSource +// +// Created by 최정인 on 7/30/25. +// + +enum RoutineEndpoint { + case fetchRoutines(startDate: String, endDate: String) +} + +extension RoutineEndpoint: Endpoint { + var baseURL: String { + return AppProperties.baseURL + "/api/v1/routines" + } + + var path: String { + switch self { + case .fetchRoutines: baseURL + } + } + + var method: HTTPMethod { + switch self { + case .fetchRoutines: .get + } + } + + var headers: [String : String] { + let headers: [String: String] = [ + "Content-Type": "application/json", + "accept": "*/*" + ] + return headers + } + + var queryParameters: [String : String] { + switch self { + case .fetchRoutines(let startDate, let endDate): + return [ + "startDate": startDate, + "endDate": endDate] + } + } + + var bodyParameters: [String : Any] { + return [:] + } + + var isAuthorized: Bool { + return true + } +} diff --git a/Projects/DataSource/Sources/Endpoint/UserEndpoint.swift b/Projects/DataSource/Sources/Endpoint/UserEndpoint.swift new file mode 100644 index 00000000..926343c6 --- /dev/null +++ b/Projects/DataSource/Sources/Endpoint/UserEndpoint.swift @@ -0,0 +1,46 @@ +// +// UserEndpoint.swift +// DataSource +// +// Created by 최정인 on 7/30/25. +// + +enum UserEndpoint { + case loadNickname +} + +extension UserEndpoint: Endpoint { + var baseURL: String { + return AppProperties.baseURL + "/api/v1/users/infos" + } + + var path: String { + return baseURL + } + + var method: HTTPMethod { + switch self { + case .loadNickname: .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/Repository/AuthRepository.swift b/Projects/DataSource/Sources/Repository/AuthRepository.swift index 949d8026..1d15ec65 100644 --- a/Projects/DataSource/Sources/Repository/AuthRepository.swift +++ b/Projects/DataSource/Sources/Repository/AuthRepository.swift @@ -19,15 +19,12 @@ final class AuthRepository: AuthRepositoryProtocol { // 카카오 로그인을 진행합니다. func kakaoLogin() async throws -> UserEntity { let accessToken = try await fetchKakaoToken() - let (nickname, profileImageUrl) = try await fetchKakaoUserInfo() let user = try await requestServerLogin( socialType: .kakao, nickname: nil, token: accessToken) - try saveNickname(nickname: nickname) try saveSocialLoginType(socialLoginType: .kakao) - try saveUserProfileImageUrl(profileImageUrl: profileImageUrl) return user } @@ -93,25 +90,6 @@ final class AuthRepository: AuthRepositoryProtocol { } } - // 카카오 SDK를 통해 유저의 정보(카카오 닉네임, 프로필 이미지)를 받아옵니다. - private func fetchKakaoUserInfo() async throws -> (nickname: String, profileImageUrl: URL) { - try await withCheckedThrowingContinuation { continuation in - let resultHandler: (User?, Error?) -> Void = { user, error in - if let error { - continuation.resume(throwing: AuthError.unknown(error)) - } else if - let nickname = user?.kakaoAccount?.profile?.nickname, - let profileImageUrl = user?.kakaoAccount?.profile?.profileImageUrl { - continuation.resume(returning: (nickname, profileImageUrl)) - } else { - continuation.resume(throwing: AuthError.kakaoUserInformationFetchFailed) - } - } - - UserApi.shared.me(completion: resultHandler) - } - } - // 서버 로그인을 진행합니다. private func requestServerLogin( socialType: SocialLoginType, diff --git a/Projects/DataSource/Sources/Repository/EmotionRepository.swift b/Projects/DataSource/Sources/Repository/EmotionRepository.swift index d2ed0de1..6170fb0e 100644 --- a/Projects/DataSource/Sources/Repository/EmotionRepository.swift +++ b/Projects/DataSource/Sources/Repository/EmotionRepository.swift @@ -19,6 +19,15 @@ final class EmotionRepository: EmotionRepositoryProtocol { return emotionEntities } + func fetchEmotion(date: String) async throws -> EmotionEntity? { + let endpoint = EmotionEndpoint.fetchEmotion(date: date) + guard let response = try await networkService.request(endpoint: endpoint, type: EmotionResponseDTO.self) + else { throw NetworkError.unknown(description: "Emotion Reponse를 받아오지 못했습니다.") } + + let emotionEntity = response.toEmotionEntity() + return emotionEntity + } + 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) diff --git a/Projects/DataSource/Sources/Repository/RoutineRepository.swift b/Projects/DataSource/Sources/Repository/RoutineRepository.swift new file mode 100644 index 00000000..46d92e13 --- /dev/null +++ b/Projects/DataSource/Sources/Repository/RoutineRepository.swift @@ -0,0 +1,24 @@ +// +// RoutineRepository.swift +// DataSource +// +// Created by 최정인 on 7/30/25. +// + +import Domain + +final class RoutineRepository: RoutineRepositoryProtocol { + private let networkService = NetworkService.shared + + func fetchRoutines(from startDate: String, to endDate: String) async throws -> [String: [RoutineEntity]] { + let endpoint = RoutineEndpoint.fetchRoutines(startDate: startDate, endDate: endDate) + guard let response = try await networkService.request(endpoint: endpoint, type: RoutineDictionaryDTO.self) + else { return [:] } + + var result: [String: [RoutineEntity]] = [:] + for (date, routineDTO) in response.routines { + result[date] = routineDTO.compactMap({ $0.toRoutineEntity() }) + } + return result + } +} diff --git a/Projects/DataSource/Sources/Repository/UserDataRepository.swift b/Projects/DataSource/Sources/Repository/UserDataRepository.swift index 7d037b78..f71315cf 100644 --- a/Projects/DataSource/Sources/Repository/UserDataRepository.swift +++ b/Projects/DataSource/Sources/Repository/UserDataRepository.swift @@ -14,13 +14,12 @@ final class UserDataRepository: UserDataRepositoryProtocol { private let userDefaultsStorage = UserDefaultsStorage.shared private let tokenManager = TokenManager.shared - func loadNickname() throws -> String { - // TODO: 서버에서 닉넴 보내준대요 - guard let nickname: String = userDefaultsStorage.load(forKey: UserDefaultsKey.nickname.rawValue) else { - throw UserError.nicknameLoadFailed - } + func loadNickname() async throws -> String { + let endpoint = UserEndpoint.loadNickname + guard let user = try await networkService.request(endpoint: endpoint, type: UserDataResponseDTO.self) + else { return "" } - return nickname + return user.nickname } func reissueToken() async -> Bool { diff --git a/Projects/Domain/Sources/DomainDependencyAssembler.swift b/Projects/Domain/Sources/DomainDependencyAssembler.swift index 5ea14c30..3f1613b5 100644 --- a/Projects/Domain/Sources/DomainDependencyAssembler.swift +++ b/Projects/Domain/Sources/DomainDependencyAssembler.swift @@ -52,5 +52,19 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { return ResultRecommendedRoutineUseCase(onboardingRepository: onboardingRepository, emotionRepository: emotionRepository) } + + DIContainer.shared.register(type: UserDataUseCaseProtocol.self) { container in + guard let userDataRepository = container.resolve(type: UserDataRepositoryProtocol.self) + else { fatalError("userDataRepository 의존성이 등록되지 않았습니다.") } + + return UserDataUseCase(userDataRepository: userDataRepository) + } + + DIContainer.shared.register(type: RoutineUseCaseProtocol.self) { container in + guard let routineRepository = container.resolve(type: RoutineRepositoryProtocol.self) + else { fatalError("routineRepository 의존성이 등록되지 않았습니다.") } + + return RoutineUseCase(routineRepository: routineRepository) + } } } diff --git a/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift b/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift index 83ad0c24..3f0a2047 100644 --- a/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift +++ b/Projects/Domain/Sources/Entity/RecommendedRoutineEntity.swift @@ -11,7 +11,7 @@ public struct RecommendedRoutineEntity { public let description: String public let category: RoutineCategoryType? public let level: RoutineLevelType? - public let subRoutines: [SubRoutineEntity] + public let subRoutines: [RecommendedSubRoutineEntity] public init( id: Int, @@ -19,7 +19,7 @@ public struct RecommendedRoutineEntity { description: String, category: RoutineCategoryType?, level: RoutineLevelType?, - subRoutines: [SubRoutineEntity] + subRoutines: [RecommendedSubRoutineEntity] ) { self.id = id self.title = title @@ -30,7 +30,7 @@ public struct RecommendedRoutineEntity { } } -public struct SubRoutineEntity { +public struct RecommendedSubRoutineEntity { public let id: Int public let title: String diff --git a/Projects/Domain/Sources/Entity/RoutineEntity.swift b/Projects/Domain/Sources/Entity/RoutineEntity.swift new file mode 100644 index 00000000..6b068df5 --- /dev/null +++ b/Projects/Domain/Sources/Entity/RoutineEntity.swift @@ -0,0 +1,43 @@ +// +// RoutineEntity.swift +// Domain +// +// Created by 최정인 on 7/30/25. +// + +public struct RoutineEntity { + public let routineId: String + public let historySeq: Int + public let routineName: String + public let repeatDay: [String] + public let executionTime: String + public let subRoutineSearchResultDto: [SubRoutineEntity] + public let modifiedYn: Bool + public let routineCompletionId: Int? + public let completeYn: Bool + public let routineType: String + + public init( + routineId: String, + historySeq: Int, + routineName: String, + repeatDay: [String]?, + executionTime: String, + subRoutineSearchResultDto: [SubRoutineEntity], + modifiedYn: Bool, + routineCompletionId: Int?, + completeYn: Bool, + routineType: String + ) { + self.routineId = routineId + self.historySeq = historySeq + self.routineName = routineName + self.repeatDay = repeatDay ?? [] + self.executionTime = executionTime + self.subRoutineSearchResultDto = subRoutineSearchResultDto + self.modifiedYn = modifiedYn + self.routineCompletionId = routineCompletionId + self.completeYn = completeYn + self.routineType = routineType + } +} diff --git a/Projects/Domain/Sources/Entity/SubRoutineEntity.swift b/Projects/Domain/Sources/Entity/SubRoutineEntity.swift new file mode 100644 index 00000000..5b423d4b --- /dev/null +++ b/Projects/Domain/Sources/Entity/SubRoutineEntity.swift @@ -0,0 +1,37 @@ +// +// SubRoutineEntity.swift +// Domain +// +// Created by 최정인 on 7/30/25. +// + +public struct SubRoutineEntity: Decodable { + public let subRoutineId: String + public let historySeq: Int + public let subRoutineName: String + public let modifiedYn: Bool + public let sortOrder: Int + public let routineCompletionId: Int? + public let completeYn: Bool + public let routineType: String + + public init( + subRoutineId: String, + historySeq: Int, + subRoutineName: String, + modifiedYn: Bool, + sortOrder: Int, + routineCompletionId: Int?, + completeYn: Bool, + routineType: String + ) { + self.subRoutineId = subRoutineId + self.historySeq = historySeq + self.subRoutineName = subRoutineName + self.modifiedYn = modifiedYn + self.sortOrder = sortOrder + self.routineCompletionId = routineCompletionId + self.completeYn = completeYn + self.routineType = routineType + } +} diff --git a/Projects/Domain/Sources/Protocol/Repository/EmotionRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/EmotionRepositoryProtocol.swift index fcc3a7ae..925d07fb 100644 --- a/Projects/Domain/Sources/Protocol/Repository/EmotionRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Protocol/Repository/EmotionRepositoryProtocol.swift @@ -10,6 +10,11 @@ public protocol EmotionRepositoryProtocol { /// 감정 구슬 목록을 불러옵니다. /// - Returns: 조회된 감정 구슬 목록 func fetchEmotions() async throws -> [EmotionEntity] + + /// 해당하는 날짜에 등록한 감정 구슬을 조회합니다. + /// - Parameter date: 조회하고 싶은 날짜 (yyyy-MM-dd) + /// - Returns: 등록한 감정 구슬 + func fetchEmotion(date: String) async throws -> EmotionEntity? /// 감정 구슬을 등록합니다. /// - Parameter emotion: 감정 구슬 String 값 diff --git a/Projects/Domain/Sources/Protocol/Repository/RoutineRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/RoutineRepositoryProtocol.swift new file mode 100644 index 00000000..ee13d44b --- /dev/null +++ b/Projects/Domain/Sources/Protocol/Repository/RoutineRepositoryProtocol.swift @@ -0,0 +1,15 @@ +// +// RoutineRepositoryProtocol.swift +// Domain +// +// Created by 최정인 on 7/30/25. +// + +// 루틴 관련 로직(조회, 완료, 등록, 삭제 등)을 수행하는 Repository +public protocol RoutineRepositoryProtocol { + /// 루틴을 조회합니다. (기간) + /// - Parameters: + /// - startDate: 조회 시작 날짜 + /// - endDate: 조회 종료 날짜 + func fetchRoutines(from startDate: String, to endDate: String) async throws -> [String: [RoutineEntity]] +} diff --git a/Projects/Domain/Sources/Protocol/Repository/UserDataRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/UserDataRepositoryProtocol.swift index 90229f6c..3c5c5390 100644 --- a/Projects/Domain/Sources/Protocol/Repository/UserDataRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Protocol/Repository/UserDataRepositoryProtocol.swift @@ -9,7 +9,7 @@ public protocol UserDataRepositoryProtocol { /// 저장한 닉네임을 가져옵니다. /// - Returns: 유저 닉네임 - func loadNickname() throws -> String + func loadNickname() async throws -> String /// 토큰 재발급을 진행합니다. func reissueToken() async -> Bool diff --git a/Projects/Domain/Sources/Protocol/UseCase/EmotionUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/EmotionUseCaseProtocol.swift index c4d85fdb..a8696504 100644 --- a/Projects/Domain/Sources/Protocol/UseCase/EmotionUseCaseProtocol.swift +++ b/Projects/Domain/Sources/Protocol/UseCase/EmotionUseCaseProtocol.swift @@ -5,8 +5,15 @@ // Created by 최정인 on 7/28/25. // +import Foundation + public protocol EmotionUseCaseProtocol { /// 감정 구슬 목록을 불러옵니다. /// - Returns: 조회된 감정 구슬 목록 func fetchEmotions() async throws -> [EmotionEntity] + + /// 해당하는 날짜에 등록된 감정 구슬을 조회합니다. + /// - Parameter date: 조회하고 싶은 날짜 + /// - Returns: 감정 구슬 + func fetchEmotion(date: Date) async throws -> EmotionEntity? } diff --git a/Projects/Domain/Sources/Protocol/UseCase/RoutineUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/RoutineUseCaseProtocol.swift new file mode 100644 index 00000000..bb129a62 --- /dev/null +++ b/Projects/Domain/Sources/Protocol/UseCase/RoutineUseCaseProtocol.swift @@ -0,0 +1,12 @@ +// +// RoutineUseCaseProtocol.swift +// Domain +// +// Created by 최정인 on 7/30/25. +// + +import Foundation + +public protocol RoutineUseCaseProtocol { + func fetchRoutines(startDate: Date, endDate: Date) async throws -> [String: [RoutineEntity]] +} diff --git a/Projects/Domain/Sources/Protocol/UseCase/UserDataUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/UserDataUseCaseProtocol.swift new file mode 100644 index 00000000..bfab0194 --- /dev/null +++ b/Projects/Domain/Sources/Protocol/UseCase/UserDataUseCaseProtocol.swift @@ -0,0 +1,10 @@ +// +// UserDataUseCaseProtocol.swift +// Domain +// +// Created by 최정인 on 7/30/25. +// + +public protocol UserDataUseCaseProtocol { + func loadNickname() async throws -> String +} diff --git a/Projects/Domain/Sources/UseCase/Emotion/EmotionUseCase.swift b/Projects/Domain/Sources/UseCase/Emotion/EmotionUseCase.swift index b433d59a..70d49976 100644 --- a/Projects/Domain/Sources/UseCase/Emotion/EmotionUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Emotion/EmotionUseCase.swift @@ -5,6 +5,9 @@ // Created by 최정인 on 7/28/25. // +import Foundation +import Shared + public final class EmotionUseCase: EmotionUseCaseProtocol { private let emotionRepository: EmotionRepositoryProtocol @@ -16,4 +19,11 @@ public final class EmotionUseCase: EmotionUseCaseProtocol { let emotions = try await emotionRepository.fetchEmotions() return emotions } + + public func fetchEmotion(date: Date) async throws -> EmotionEntity? { + let dateString = date.convertToString(dateType: .yearMonthDate) + + let emotion = try await emotionRepository.fetchEmotion(date: dateString) + return emotion + } } diff --git a/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift b/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift new file mode 100644 index 00000000..58ae3e52 --- /dev/null +++ b/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift @@ -0,0 +1,25 @@ +// +// RoutineUseCase.swift +// Domain +// +// Created by 최정인 on 7/30/25. +// + +import Foundation +import Shared + +public final class RoutineUseCase: RoutineUseCaseProtocol { + private let routineRepository: RoutineRepositoryProtocol + + public init(routineRepository: RoutineRepositoryProtocol) { + self.routineRepository = routineRepository + } + + public func fetchRoutines(startDate: Date, endDate: Date) async throws -> [String: [RoutineEntity]] { + let start = startDate.convertToString(dateType: .yearMonthDate) + let end = endDate.convertToString(dateType: .yearMonthDate) + + var routineEntities = try await routineRepository.fetchRoutines(from: start, to: end) + return routineEntities + } +} diff --git a/Projects/Domain/Sources/UseCase/UserData/UserDataUseCase.swift b/Projects/Domain/Sources/UseCase/UserData/UserDataUseCase.swift new file mode 100644 index 00000000..d43d59b3 --- /dev/null +++ b/Projects/Domain/Sources/UseCase/UserData/UserDataUseCase.swift @@ -0,0 +1,19 @@ +// +// UserDataUseCase.swift +// Domain +// +// Created by 최정인 on 7/30/25. +// + +public final class UserDataUseCase: UserDataUseCaseProtocol { + private let userDataRepository: UserDataRepositoryProtocol + + public init(userDataRepository: UserDataRepositoryProtocol) { + self.userDataRepository = userDataRepository + } + + public func loadNickname() async throws -> String { + let nickname = try await userDataRepository.loadNickname() + return nickname + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/Contents.json new file mode 100644 index 00000000..eb8320bb --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "default_emotion_graphic.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "default_emotion_graphic@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "default_emotion_graphic@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic.png b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic.png new file mode 100644 index 00000000..7f8e58fb Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@2x.png b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@2x.png new file mode 100644 index 00000000..c83aae35 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@3x.png b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@3x.png new file mode 100644 index 00000000..c3397ac5 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/Contents.json new file mode 100644 index 00000000..93f35726 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "edit_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "edit_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "edit_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon.png b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon.png new file mode 100644 index 00000000..78b83c43 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@2x.png new file mode 100644 index 00000000..73e479e7 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@3x.png new file mode 100644 index 00000000..9f7ca84b Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/Contents.json new file mode 100644 index 00000000..1dd6a534 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "repeat_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "repeat_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "repeat_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon.png b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon.png new file mode 100644 index 00000000..995bf98a Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@2x.png new file mode 100644 index 00000000..5f9f280f Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@3x.png new file mode 100644 index 00000000..85e83d6f Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/Contents.json new file mode 100644 index 00000000..6ffa164e --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "routine_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "routine_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "routine_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon.png b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon.png new file mode 100644 index 00000000..934b8f7b Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@2x.png new file mode 100644 index 00000000..86d90524 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@3x.png new file mode 100644 index 00000000..4f548f21 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/Contents.json new file mode 100644 index 00000000..a4801648 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "subRoutine_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "subRoutine_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "subRoutine_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon.png b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon.png new file mode 100644 index 00000000..87eef327 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@2x.png new file mode 100644 index 00000000..cfb5731a Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@3x.png new file mode 100644 index 00000000..6a553062 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/Contents.json new file mode 100644 index 00000000..cd6ea491 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "trash_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "trash_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "trash_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon.png b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon.png new file mode 100644 index 00000000..ee5a739d Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@2x.png new file mode 100644 index 00000000..7b715c16 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@3x.png new file mode 100644 index 00000000..96099609 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@3x.png differ diff --git a/Projects/Presentation/Sources/Common/Component/CustomBottomSheet.swift b/Projects/Presentation/Sources/Common/Component/CustomBottomSheet.swift index 939d0610..309a6fa6 100644 --- a/Projects/Presentation/Sources/Common/Component/CustomBottomSheet.swift +++ b/Projects/Presentation/Sources/Common/Component/CustomBottomSheet.swift @@ -121,7 +121,7 @@ final class CustomBottomSheet: UIViewController { } } - @objc private func dismissBottomSheet() { + @objc func dismissBottomSheet() { UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { self.dimmedView.alpha = 0 self.bottomSheetBottomConstraint?.update(offset: self.maxHeight) diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift index 0c622f72..f98a96b0 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilGraphic.swift @@ -14,4 +14,5 @@ enum BitnagilGraphic { static let introGraphic = UIImage(named: "intro_graphic", in: bundle, with: nil) static let onboardingGraphic = UIImage(named: "onboarding_graphic", in: bundle, with: nil) + static let defaultEmotionGraphic = UIImage(named: "default_emotion_graphic", in: bundle, with: nil) } diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 789b7856..088a9dfa 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -47,6 +47,13 @@ enum BitnagilIcon { static let uncheckedIcon = UIImage(named: "unchecked_icon", in: bundle, with: nil) static let checkedIcon = UIImage(named: "checked_icon", in: bundle, with: nil) static let routineCreationIcon = UIImage(named: "routine_creation_icon", in: bundle, with: nil) + + // MARK: - Routine Detail Icons + static let routineIcon = UIImage(named: "routine_icon", in: bundle, with: nil) + static let subRoutineIcon = UIImage(named: "subRoutine_icon", in: bundle, with: nil) + static let repeatIcon = UIImage(named: "repeat_icon", in: bundle, with: nil) + static let editIcon = UIImage(named: "edit_icon", in: bundle, with: nil) + static let trashIcon = UIImage(named: "trash_icon", in: bundle, with: nil) } enum Direction { diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 4457d559..605916d8 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -18,8 +18,20 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { public func assemble() { preAssembler.assemble() - DIContainer.shared.register(type: HomeViewModel.self) { _ in - return HomeViewModel() + DIContainer.shared.register(type: HomeViewModel.self) { container in + guard let userDataUseCase = container.resolve(type: UserDataUseCaseProtocol.self) + else { fatalError("userDataUseCase 의존성이 등록되지 않았습니다.") } + + guard let routineUseCase = container.resolve(type: RoutineUseCaseProtocol.self) + else { fatalError("routineUseCase 의존성이 등록되지 않았습니다.") } + + guard let emotionUseCase = container.resolve(type: EmotionUseCaseProtocol.self) + else { fatalError("emotionUseCase 의존성이 등록되지 않았습니다.") } + + return HomeViewModel( + routineUseCase: routineUseCase, + userDataUseCase: userDataUseCase, + emotionUseCase: emotionUseCase) } DIContainer.shared.register(type: LoginViewModel.self) { container in diff --git a/Projects/Presentation/Sources/Home/Model/MainRoutine.swift b/Projects/Presentation/Sources/Home/Model/MainRoutine.swift index dfa2ba7d..bfaec215 100644 --- a/Projects/Presentation/Sources/Home/Model/MainRoutine.swift +++ b/Projects/Presentation/Sources/Home/Model/MainRoutine.swift @@ -5,12 +5,26 @@ // Created by 최정인 on 7/18/25. // +import Domain import Foundation struct MainRoutine { - let id: Int - let startTime: Date + let id: String let title: String var isDone: Bool + let startTime: Date + let repeatDay: [Week] var subRoutines: [SubRoutine] } + +extension RoutineEntity { + func toMainRoutine() -> MainRoutine { + return MainRoutine( + id: routineId, + title: routineName, + isDone: completeYn, + startTime: Date.convertToDate(from: executionTime, dateType: .time) ?? Date(), + repeatDay: repeatDay.compactMap({ Week(rawValue: $0) }), + subRoutines: subRoutineSearchResultDto.map({ $0.toSubRoutine() })) + } +} diff --git a/Projects/Presentation/Sources/Home/Model/SubRoutine.swift b/Projects/Presentation/Sources/Home/Model/SubRoutine.swift index 318d53cd..e90867c3 100644 --- a/Projects/Presentation/Sources/Home/Model/SubRoutine.swift +++ b/Projects/Presentation/Sources/Home/Model/SubRoutine.swift @@ -5,8 +5,21 @@ // Created by 최정인 on 7/18/25. // +import Domain + struct SubRoutine: Hashable { - let id: Int + let id: String let title: String var isDone: Bool + let sortIndex: Int +} + +extension SubRoutineEntity { + func toSubRoutine() -> SubRoutine { + return SubRoutine( + id: subRoutineId, + title: subRoutineName, + isDone: completeYn, + sortIndex: sortOrder) + } } diff --git a/Projects/Presentation/Sources/Home/View/Component/DateView.swift b/Projects/Presentation/Sources/Home/View/Component/DateView.swift index e5a88db5..4e93fc0f 100644 --- a/Projects/Presentation/Sources/Home/View/Component/DateView.swift +++ b/Projects/Presentation/Sources/Home/View/Component/DateView.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/23/25. // +import Shared import SnapKit import UIKit diff --git a/Projects/Presentation/Sources/Home/View/Component/MainRoutineView.swift b/Projects/Presentation/Sources/Home/View/Component/MainRoutineView.swift index ac08c684..39a67d31 100644 --- a/Projects/Presentation/Sources/Home/View/Component/MainRoutineView.swift +++ b/Projects/Presentation/Sources/Home/View/Component/MainRoutineView.swift @@ -106,7 +106,6 @@ final class MainRoutineView: UIView { make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(Layout.checkButtonLeadingSpacing) make.height.equalTo(Layout.checkButtonHeight) - make.width.equalTo(Layout.checkButtonWidth) } buttonStackView.snp.makeConstraints { make in diff --git a/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift b/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift index b74c204d..fc04e9f4 100644 --- a/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift +++ b/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/18/25. // +import SnapKit import UIKit protocol RoutineViewDelegate: AnyObject { @@ -37,7 +38,7 @@ final class RoutineView: UIView { private lazy var mainRoutineView = MainRoutineView(mainRoutine: routine) private let subRoutineLabel = UILabel() private let subRoutineStackView = UIStackView() - private var subRoutineButtons: [Int: SubRoutineButton] = [:] + private var subRoutineButtons: [String: SubRoutineButton] = [:] private var routine: MainRoutine weak var delegate: RoutineViewDelegate? @@ -65,7 +66,7 @@ final class RoutineView: UIView { } private func configureAttribute() { - timeLabel.text = "\(routine.startTime.convertToString(dateType: .amPmTime))부터 시작" + timeLabel.text = "\(routine.startTime.convertToString(dateType: .amPmTimeShort))부터 시작" timeLabel.font = BitnagilFont(style: .caption1, weight: .regular).font timeLabel.textColor = BitnagilColor.navy300 @@ -142,6 +143,7 @@ final class RoutineView: UIView { }, for: .touchUpInside) subRoutineStackView.addArrangedSubview(subRoutineView) subRoutineView.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() make.height.equalTo(Layout.subRoutineViewHeight) } } diff --git a/Projects/Presentation/Sources/Home/View/Component/SubRoutineButton.swift b/Projects/Presentation/Sources/Home/View/Component/SubRoutineButton.swift index 63285053..37b78e71 100644 --- a/Projects/Presentation/Sources/Home/View/Component/SubRoutineButton.swift +++ b/Projects/Presentation/Sources/Home/View/Component/SubRoutineButton.swift @@ -5,6 +5,7 @@ // Created by 최정인 on 7/18/25. // +import SnapKit import UIKit final class SubRoutineButton: UIButton { @@ -57,6 +58,7 @@ final class SubRoutineButton: UIButton { routineLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(checkButton.snp.trailing).offset(Layout.routineLabelLeadingSpacing) + make.trailing.equalToSuperview() } } diff --git a/Projects/Presentation/Sources/Home/View/HomeView.swift b/Projects/Presentation/Sources/Home/View/HomeView.swift index efd0ae75..5560b9dc 100644 --- a/Projects/Presentation/Sources/Home/View/HomeView.swift +++ b/Projects/Presentation/Sources/Home/View/HomeView.swift @@ -6,6 +6,7 @@ // import Combine +import Kingfisher import Shared import SnapKit import UIKit @@ -23,9 +24,9 @@ final class HomeView: BaseViewController { static let registerEmotionButtonTopSpacing: CGFloat = 3 static let registerEmotionButtonHeight: CGFloat = 44 static let registerEmotionButtonWidth: CGFloat = 136 - static let emotionOrbViewTopSpacing: CGFloat = 81 + static let emotionOrbViewTopSpacing: CGFloat = 46 static let emotionOrbViewTrailingSpacing: CGFloat = 35 - static let emotionOrbViewSize: CGFloat = 102 + static let emotionOrbViewSize: CGFloat = 172 static let contentViewCornerRadius: CGFloat = 20 static let weekViewHeight: CGFloat = 127 static let routineSortButtonTrailingSpacing: CGFloat = 8 @@ -37,12 +38,24 @@ final class HomeView: BaseViewController { static let emptyViewHeight: CGFloat = 120 static let collapsedTop: CGFloat = 225 static let expandedTop: CGFloat = 40 + static let floatingButtonBottomSpacing: CGFloat = 19 + static let floatingButtonSize: CGFloat = 52 + static let floatingMenuBottomSpacing: CGFloat = 15 + static let floatingMenuHeight: CGFloat = 64 + static let floatingMenuWidth: CGFloat = 144 + static let tooltipViewTailLeadingSpacing: CGFloat = 78.68 + static let tooltipViewLeadingSpacing: CGFloat = 76 + static let tooltipViewBottomSpacing: CGFloat = 4 + static let tooltipViewWidth: CGFloat = 176 + static let tooltipViewHeight: CGFloat = 47 + static let routineDetailViewDefaultHeight: CGFloat = 367 + static let routineDetailViewSubRoutineHeight: CGFloat = 25 } private let gradientLayer = CAGradientLayer() private let homeLabel = UILabel() private let informationButton = UIButton() - private let emotionOrbView = UIView() + private let emotionOrbView = UIImageView() private let registerEmotionButton = HomeRegisterEmotionButton() private let contentView = UIView() @@ -54,6 +67,14 @@ final class HomeView: BaseViewController { private let routineSortView = SelectableItemTableView(items: [RoutineSortType.complete, RoutineSortType.incomplete]) private let routineStackView = UIStackView() + private let tooltipView = TooltipView(tailPosition: .offsetFromLeading(Layout.tooltipViewTailLeadingSpacing)) + + private var isShowingFloatingMenu: Bool = false + private let dimmedView = UIView() + private let floatingButton = FloatingButton() + private let floatingMenu = FloatingMenuView() + private var bottomSheet: CustomBottomSheet? + private var contentViewTopConstraint: Constraint? private var cancellables: Set @@ -72,7 +93,11 @@ final class HomeView: BaseViewController { configureGradientBackground() viewModel.action(input: .loadNickname) viewModel.action(input: .fetchRoutines) - viewModel.action(input: .fetchDailyRoutines(date: Date())) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.action(input: .fetchEmotion) } override func viewDidLayoutSubviews() { @@ -87,14 +112,15 @@ final class HomeView: BaseViewController { homeLabel.textColor = BitnagilColor.gray10 informationButton.setImage(BitnagilIcon.informationIcon, for: .normal) - informationButton.addAction(UIAction { _ in - // TODO: 툴팁 뷰를 보여줘야 합니다. + informationButton.addAction(UIAction { [weak self] _ in + self?.informationButton.isSelected.toggle() + if self?.informationButton.isSelected ?? false { + self?.tooltipView.showTooltip() + } else { + self?.tooltipView.hideTooltip() + } }, for: .touchUpInside) - emotionOrbView.backgroundColor = BitnagilColor.happy - emotionOrbView.layer.masksToBounds = true - emotionOrbView.layer.cornerRadius = Layout.emotionOrbViewSize / 2 - registerEmotionButton.addAction(UIAction { [weak self] _ in guard let emotionRegisterViewModel = DIContainer.shared.resolve(type: EmotionRegisterViewModel.self) else { fatalError("emotionRegisterViewModel 의존성이 등록되지 않았습니다.") @@ -116,7 +142,12 @@ final class HomeView: BaseViewController { weekView.delegate = self emptyView.didTapRegisterRoutineButton = { - // TODO: 감정 등록 화면으로 이동해야 합니다. + guard let routineCreationViewModel = DIContainer.shared.resolve(type: RoutineCreationViewModel.self) else { + fatalError("routineCreationViewModel 의존성이 등록되지 않았습니다.") + } + let routineCreationView = RoutineCreationView(viewModel: routineCreationViewModel) + routineCreationView.hidesBottomBarWhenPushed = true + self.navigationController?.pushViewController(routineCreationView, animated: true) } routineScrollView.showsVerticalScrollIndicator = false @@ -133,6 +164,25 @@ final class HomeView: BaseViewController { routineStackView.spacing = Layout.routineStackViewSpacing routineStackView.alignment = .fill routineStackView.distribution = .fill + + tooltipView.configure(message: "감정 기록 시, 루틴을 추천 받아요!") + tooltipView.isHidden = true + let viewTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) + view.addGestureRecognizer(viewTapGesture) + + floatingButton.addAction(UIAction { [weak self] _ in + self?.toggleFloatingButton() + }, for: .touchUpInside) + + floatingMenu.isHidden = true + floatingMenu.delegate = self + + dimmedView.isHidden = true + dimmedView.backgroundColor = UIColor.black.withAlphaComponent(0.7) + dimmedView.alpha = 0 + + let dimmedViewTapGesture = UITapGestureRecognizer(target: self, action: #selector(tappedDimmedView)) + dimmedView.addGestureRecognizer(dimmedViewTapGesture) } override func configureLayout() { @@ -141,6 +191,7 @@ final class HomeView: BaseViewController { view.addSubview(homeLabel) view.addSubview(informationButton) + view.addSubview(tooltipView) view.addSubview(emotionOrbView) view.addSubview(registerEmotionButton) @@ -151,6 +202,10 @@ final class HomeView: BaseViewController { routineScrollView.addSubview(routineSortButton) routineScrollView.addSubview(routineStackView) + view.addSubview(dimmedView) + view.addSubview(floatingMenu) + view.addSubview(floatingButton) + homeLabel.snp.makeConstraints { make in make.top.equalTo(safeArea).offset(Layout.homeLabelTopSpacing) make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) @@ -163,6 +218,13 @@ final class HomeView: BaseViewController { make.size.equalTo(Layout.informationButtonSize) } + tooltipView.snp.makeConstraints { make in + make.leading.equalTo(informationButton).offset(-Layout.tooltipViewLeadingSpacing) + make.bottom.equalTo(informationButton.snp.top).offset(-Layout.tooltipViewBottomSpacing) + make.width.equalTo(Layout.tooltipViewWidth) + make.height.equalTo(Layout.tooltipViewHeight) + } + registerEmotionButton.snp.makeConstraints { make in make.leading.equalToSuperview() make.top.equalTo(homeLabel.snp.bottom).offset(Layout.registerEmotionButtonTopSpacing) @@ -172,7 +234,7 @@ final class HomeView: BaseViewController { emotionOrbView.snp.makeConstraints { make in make.top.equalTo(safeArea).offset(Layout.emotionOrbViewTopSpacing) - make.trailing.equalToSuperview().inset(Layout.emotionOrbViewTrailingSpacing) + make.trailing.equalToSuperview() make.size.equalTo(Layout.emotionOrbViewSize) } @@ -213,6 +275,23 @@ final class HomeView: BaseViewController { make.centerX.equalToSuperview() make.height.equalTo(Layout.emptyViewHeight) } + + floatingButton.snp.makeConstraints { make in + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.bottom.equalTo(safeArea).inset(Layout.floatingButtonBottomSpacing) + make.size.equalTo(Layout.floatingButtonSize) + } + + floatingMenu.snp.makeConstraints { make in + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + make.bottom.equalTo(floatingButton.snp.top).offset(-Layout.floatingMenuBottomSpacing) + make.height.equalTo(Layout.floatingMenuHeight) + make.width.equalTo(Layout.floatingMenuWidth) + } + + dimmedView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } } override func bind() { @@ -223,12 +302,29 @@ final class HomeView: BaseViewController { } .store(in: &cancellables) + viewModel.output.fetchRoutineResultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] fetchRoutineResult in + if fetchRoutineResult { + self?.viewModel.action(input: .fetchDailyRoutines(date: Date())) + } + } + .store(in: &cancellables) + viewModel.output.routinesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] routines in self?.updateRoutineView(routines: routines) } .store(in: &cancellables) + + viewModel.output.emotionPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] emotion in + self?.updateEmotionOrbView(emotion: emotion) + } + .store(in: &cancellables) + } // 홈 Graident 배경색을 설정합니다. @@ -243,6 +339,9 @@ final class HomeView: BaseViewController { // 루틴 정렬 Bottom Sheet를 보여줍니다. private func showRoutineSortBottomSheet() { + if !tooltipView.isHidden { + hideTooltipView() + } presentCustomBottomSheet(contentViewController: routineSortView, maxHeight: 192) } @@ -269,6 +368,19 @@ final class HomeView: BaseViewController { } } + // 감정 구슬 View를 업데이트 합니다. + private func updateEmotionOrbView(emotion: Emotion?) { + guard + let emotion, + let emotionOrbImageUrl = emotion.emotionImageUrl else { + emotionOrbView.image = BitnagilGraphic.defaultEmotionGraphic + return + } + emotionOrbView.kf.setImage(with: emotionOrbImageUrl) + registerEmotionButton.isEnabled = false + // TODO: 토스트뷰 보여주기 + } + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: view) let velocity = gesture.velocity(in: view) @@ -310,6 +422,47 @@ final class HomeView: BaseViewController { self.view.layoutIfNeeded() } } + + private func toggleFloatingButton() { + if !tooltipView.isHidden { + hideTooltipView() + } + + floatingButton.toggle() + isShowingFloatingMenu.toggle() + + floatingMenu.isHidden = !isShowingFloatingMenu + dimmedView.isHidden = !isShowingFloatingMenu + + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut]) { + self.dimmedView.alpha = self.isShowingFloatingMenu ? 1 : 0 + self.floatingMenu.alpha = self.isShowingFloatingMenu ? 1 : 0 + } + } + + @objc private func tappedDimmedView() { + toggleFloatingButton() + } + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: view) + + // 탭한 곳이 버튼 영역인 경우 + if informationButton.frame.contains(location) { + hideTooltipView() + return + } + + // 탭한 곳이 tooltip 영역 밖인 경우 + if !tooltipView.frame.contains(location) { + hideTooltipView() + } + } + + private func hideTooltipView() { + informationButton.isSelected = false + tooltipView.hideTooltip() + } } // MARK: RoutineViewDelegate @@ -320,7 +473,13 @@ extension HomeView: RoutineViewDelegate { } func routineView(_ sender: RoutineView, didTapMainRoutineMoreButton mainRoutine: MainRoutine) { - // TODO: 더보기 Bottom Sheet + let maxHeight = Layout.routineDetailViewDefaultHeight + CGFloat(mainRoutine.subRoutines.count - 1) * Layout.routineDetailViewSubRoutineHeight + let routineDetailView = RoutineDetailView(routine: mainRoutine) + routineDetailView.delegate = self + bottomSheet = CustomBottomSheet(contentViewController: routineDetailView, maxHeight: maxHeight) + if let bottomSheet { + present(bottomSheet, animated: true) + } } func routineView(_ sender: RoutineView, didTapSubRoutineCheckButton subRoutine: SubRoutine) { @@ -339,10 +498,43 @@ extension HomeView: SelectableItemTableViewDelegate { // MARK: WeekViewDelegate extension HomeView: WeekViewDelegate { func weekView(_ sender: WeekView, didMoveWeek weekStartDate: Date) { - // TODO: 그 전 주 혹은 다음 주 데이터 받아와야 합니다. + viewModel.action(input: .fetchDailyRoutines(date: weekStartDate)) } func weekView(_ sender: WeekView, didSelectDate date: Date) { viewModel.action(input: .fetchDailyRoutines(date: date)) } } + +// MARK: FloatingMenuViewDelegate +extension HomeView: FloatingMenuViewDelegate { + func floatingMenuDidTapRegisterRoutineButton(_ sender: FloatingMenuView) { + toggleFloatingButton() + guard let routineCreationViewModel = DIContainer.shared.resolve(type: RoutineCreationViewModel.self) else { + fatalError("routineCreationViewModel 의존성이 등록되지 않았습니다.") + } + let routineCreationView = RoutineCreationView(viewModel: routineCreationViewModel) + routineCreationView.hidesBottomBarWhenPushed = true + self.navigationController?.pushViewController(routineCreationView, animated: true) + } +} + +// MARK: RoutineDetailViewDelegate +extension HomeView: RoutineDetailViewDelegate { + func routineDetailView(_ sender: RoutineDetailView, didEditRoutine routine: MainRoutine) { + if let bottomSheet { + bottomSheet.dismissBottomSheet() + self.bottomSheet = nil + } + guard let routineCreationViewModel = DIContainer.shared.resolve(type: RoutineCreationViewModel.self) else { + fatalError("routineCreationViewModel 의존성이 등록되지 않았습니다.") + } + let routineCreationView = RoutineCreationView(viewModel: routineCreationViewModel) + routineCreationView.hidesBottomBarWhenPushed = true + self.navigationController?.pushViewController(routineCreationView, animated: true) + } + + func routineDetailView(_ sender: RoutineDetailView, didDeleteRoutine routine: MainRoutine) { + // TODO: 루틴 삭제 + } +} diff --git a/Projects/Presentation/Sources/Home/View/RoutineDetailView.swift b/Projects/Presentation/Sources/Home/View/RoutineDetailView.swift new file mode 100644 index 00000000..0a0e05c0 --- /dev/null +++ b/Projects/Presentation/Sources/Home/View/RoutineDetailView.swift @@ -0,0 +1,344 @@ +// +// RoutineDetailView.swift +// Presentation +// +// Created by 최정인 on 7/30/25. +// + +import SnapKit +import UIKit +import Shared + +protocol RoutineDetailViewDelegate: AnyObject { + func routineDetailView(_ sender: RoutineDetailView, didEditRoutine routine: MainRoutine) + func routineDetailView(_ sender: RoutineDetailView, didDeleteRoutine routine: MainRoutine) +} + +final class RoutineDetailView: UIViewController { + + private enum Layout { + static let horizontalMargin: CGFloat = 20 + static let contentStackViewSpacing: CGFloat = 24 + static let contentStackViewTopSpacing: CGFloat = 32 + static let routineIconSize: CGFloat = 28 + static let routineLabelHeight: CGFloat = 20 + static let routineLabelWidth: CGFloat = 60 + static let routineLabelLeadingSpacing: CGFloat = 9 + static let mainRoutineInfoStackViewSpacing: CGFloat = 9 + static let subRoutineStackViewSpacing: CGFloat = 0 + static let subRoutineInfoStackViewSpacing: CGFloat = 9 + static let subRoutineInfoStackViewHeight: CGFloat = 28 + static let subRoutineLabelHeight: CGFloat = 24 + static let repeatRoutineStackViewSpacing: CGFloat = 0 + static let repeatRoutineInfoStackViewSpacing: CGFloat = 9 + static let repeatRoutineTimeLabelHeight: CGFloat = 18 + static let buttonStackViewSpacing: CGFloat = 13 + static let buttonStackViewTopSpacing: CGFloat = 47 + static let buttonImagePadding: CGFloat = 8 + static let buttonCornerRadius: CGFloat = 12 + static let buttonHeight: CGFloat = 54 + } + + private let contentStackView = UIStackView() + + // 메인 루틴 + private let mainRoutineInfoStackView = UIStackView() + private let mainRoutineIcon = UIImageView() + private let mainRoutineLabel = UILabel() + private let mainRoutineTitleLabel = UILabel() + + // 서브 루틴 + private let subRoutineStackView = UIStackView() + private let subRoutineInfoStackView = UIStackView() + private let subRoutineIcon = UIImageView() + private let subRoutineLabel = UILabel() + private let subRoutineTitleLabel = UILabel() + + // 루틴 반복 + private let repeatRoutineStackView = UIStackView() + private let repeatRoutineInfoStackView = UIStackView() + private let repeatRoutineIcon = UIImageView() + private let repeatRoutineLabel = UILabel() + private let repeatRoutineTitleLabel = UILabel() + private let repeatRoutineTimeLabel = UILabel() + + // 버튼 + private let buttonStackView = UIStackView() + private let editButton = UIButton() + private let deleteButton = UIButton() + + weak var delegate: RoutineDetailViewDelegate? + private let routine: MainRoutine + init(routine: MainRoutine) { + self.routine = routine + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureAttribute() + configureLayout() + } + + private func configureAttribute() { + // Content StackView + contentStackView.axis = .vertical + contentStackView.spacing = Layout.contentStackViewSpacing + + // 메인 루틴 + mainRoutineInfoStackView.axis = .horizontal + mainRoutineInfoStackView.alignment = .center + mainRoutineInfoStackView.spacing = Layout.mainRoutineInfoStackViewSpacing + + mainRoutineLabel.text = "루틴 이름" + mainRoutineLabel.font = BitnagilFont(style: .body2, weight: .medium).font + mainRoutineLabel.textColor = BitnagilColor.gray50 + + mainRoutineIcon.image = BitnagilIcon.routineIcon + mainRoutineIcon.contentMode = .scaleAspectFit + + mainRoutineTitleLabel.text = routine.title + mainRoutineTitleLabel.font = BitnagilFont(style: .body2, weight: .semiBold).font + mainRoutineTitleLabel.textColor = BitnagilColor.gray10 + mainRoutineTitleLabel.textAlignment = .right + + // 서브 루틴 + subRoutineStackView.axis = .vertical + subRoutineStackView.spacing = Layout.subRoutineStackViewSpacing + + subRoutineInfoStackView.axis = .horizontal + subRoutineInfoStackView.alignment = .center + subRoutineInfoStackView.spacing = Layout.subRoutineInfoStackViewSpacing + + subRoutineLabel.text = "세부 루틴" + subRoutineLabel.font = BitnagilFont(style: .body2, weight: .medium).font + subRoutineLabel.textColor = BitnagilColor.gray50 + + subRoutineIcon.image = BitnagilIcon.subRoutineIcon + subRoutineIcon.contentMode = .scaleAspectFit + + subRoutineTitleLabel.text = "세부 루틴 없음" + subRoutineTitleLabel.font = BitnagilFont(style: .body2, weight: .semiBold).font + subRoutineTitleLabel.textColor = BitnagilColor.gray10 + subRoutineTitleLabel.textAlignment = .right + + + // 루틴 반복 + repeatRoutineStackView.axis = .vertical + repeatRoutineStackView.spacing = Layout.repeatRoutineStackViewSpacing + + repeatRoutineInfoStackView.axis = .horizontal + repeatRoutineInfoStackView.alignment = .center + repeatRoutineInfoStackView.spacing = Layout.repeatRoutineInfoStackViewSpacing + + repeatRoutineLabel.text = "루틴 반복" + repeatRoutineLabel.font = BitnagilFont(style: .body2, weight: .medium).font + repeatRoutineLabel.textColor = BitnagilColor.gray50 + + repeatRoutineIcon.image = BitnagilIcon.repeatIcon + repeatRoutineIcon.contentMode = .scaleAspectFit + + repeatRoutineTitleLabel.text = "반복 안함" + repeatRoutineTitleLabel.font = BitnagilFont(style: .body2, weight: .semiBold).font + repeatRoutineTitleLabel.textColor = BitnagilColor.gray10 + repeatRoutineTitleLabel.textAlignment = .right + + repeatRoutineTimeLabel.text = "\(routine.startTime.convertToString(dateType: .amPmTimeShort)) 시작" + repeatRoutineTimeLabel.font = BitnagilFont(style: .caption1, weight: .medium).font + repeatRoutineTimeLabel.textColor = BitnagilColor.gray40 + repeatRoutineTimeLabel.textAlignment = .right + + // 버튼 + buttonStackView.axis = .horizontal + buttonStackView.spacing = Layout.buttonStackViewSpacing + buttonStackView.distribution = .fillEqually + + var editButtonConfiguration = UIButton.Configuration.filled() + editButtonConfiguration.attributedTitle = AttributedString("수정하기", attributes: .init([.font: BitnagilFont(style: .subtitle1, weight: .semiBold).font])) + editButtonConfiguration.image = BitnagilIcon.editIcon + editButtonConfiguration.imagePlacement = .leading + editButtonConfiguration.imagePadding = Layout.buttonImagePadding + editButtonConfiguration.baseBackgroundColor = BitnagilColor.navy500 + editButtonConfiguration.baseForegroundColor = .white + editButtonConfiguration.background.cornerRadius = Layout.buttonCornerRadius + editButton.configuration = editButtonConfiguration + editButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.routineDetailView(self, didEditRoutine: routine) + }, for: .touchUpInside) + + var deleteButtonConfiguration = UIButton.Configuration.filled() + deleteButtonConfiguration.attributedTitle = AttributedString("삭제하기", attributes: .init([.font: BitnagilFont(style: .subtitle1, weight: .semiBold).font])) + deleteButtonConfiguration.baseBackgroundColor = .white + deleteButtonConfiguration.baseForegroundColor = BitnagilColor.navy500 + deleteButtonConfiguration.image = BitnagilIcon.trashIcon + deleteButtonConfiguration.imagePlacement = .leading + deleteButtonConfiguration.imagePadding = Layout.buttonImagePadding + + deleteButtonConfiguration.background.strokeColor = BitnagilColor.navy500 + deleteButtonConfiguration.background.strokeWidth = 1 + deleteButtonConfiguration.background.cornerRadius = Layout.buttonCornerRadius + deleteButton.configuration = deleteButtonConfiguration + deleteButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.delegate?.routineDetailView(self, didDeleteRoutine: routine) + }, for: .touchUpInside) + } + + private func configureLayout() { + view.addSubview(contentStackView) + view.addSubview(buttonStackView) + + // Content StackView + [mainRoutineInfoStackView, subRoutineStackView, repeatRoutineStackView].forEach { + contentStackView.addArrangedSubview($0) + } + + // 메인 루틴 + [mainRoutineIcon, mainRoutineLabel, mainRoutineTitleLabel].forEach { + mainRoutineInfoStackView.addArrangedSubview($0) + } + + // 서브 루틴 + subRoutineStackView.addArrangedSubview(subRoutineInfoStackView) + [subRoutineIcon, subRoutineLabel, subRoutineTitleLabel].forEach { + subRoutineInfoStackView.addArrangedSubview($0) + } + + // 반복 루틴 + repeatRoutineStackView.addArrangedSubview(repeatRoutineInfoStackView) + repeatRoutineStackView.addArrangedSubview(repeatRoutineTimeLabel) + [repeatRoutineIcon, repeatRoutineLabel, repeatRoutineTitleLabel].forEach { + repeatRoutineInfoStackView.addArrangedSubview($0) + } + + // 버튼 + [editButton, deleteButton].forEach { + buttonStackView.addArrangedSubview($0) + } + + contentStackView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.contentStackViewTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + } + + // 메인 루틴 + mainRoutineIcon.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalToSuperview() + make.size.equalTo(Layout.routineIconSize) + } + + mainRoutineLabel.snp.makeConstraints { make in + make.leading.equalTo(mainRoutineIcon.snp.trailing).offset(Layout.routineLabelLeadingSpacing) + make.height.equalTo(Layout.routineLabelHeight) + make.width.equalTo(Layout.routineLabelWidth) + } + + mainRoutineTitleLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.height.equalTo(Layout.routineLabelHeight) + make.centerY.equalToSuperview() + } + + // 서브 루틴 + subRoutineInfoStackView.snp.makeConstraints { make in + make.height.equalTo(Layout.subRoutineInfoStackViewHeight) + } + + subRoutineIcon.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + make.size.equalTo(Layout.routineIconSize) + } + + subRoutineLabel.snp.makeConstraints { make in + make.leading.equalTo(subRoutineIcon.snp.trailing).offset(Layout.routineLabelLeadingSpacing) + make.height.equalTo(Layout.routineLabelHeight) + make.centerY.equalToSuperview() + make.width.equalTo(Layout.routineLabelWidth) + } + + subRoutineTitleLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.height.equalTo(Layout.routineLabelHeight) + make.centerY.equalToSuperview() + } + + // 반복 루틴 + repeatRoutineIcon.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalToSuperview() + make.size.equalTo(Layout.routineIconSize) + } + + repeatRoutineLabel.snp.makeConstraints { make in + make.leading.equalTo(repeatRoutineIcon.snp.trailing).offset(Layout.routineLabelLeadingSpacing) + make.height.equalTo(Layout.routineLabelHeight) + make.width.equalTo(Layout.routineLabelWidth) + } + + repeatRoutineTitleLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.height.equalToSuperview() + make.centerY.equalToSuperview() + } + + repeatRoutineTimeLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Layout.repeatRoutineTimeLabelHeight) + } + + // 버튼 (수정 · 삭제) + buttonStackView.snp.makeConstraints { make in + make.top.equalTo(contentStackView.snp.bottom).offset(Layout.buttonStackViewTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + make.height.equalTo(Layout.buttonHeight) + } + + editButton.snp.makeConstraints { make in + make.size.equalTo(Layout.buttonHeight) + } + deleteButton.snp.makeConstraints { make in + make.size.equalTo(Layout.buttonHeight) + } + + if !routine.subRoutines.isEmpty { + updateSubRoutineView() + } + + if !routine.repeatDay.isEmpty { + updateRepeatRoutine() + } + } + + private func updateSubRoutineView() { + for i in 0.. + let fetchRoutineResultPublisher: AnyPublisher let routinesPublisher: AnyPublisher<[MainRoutine], Never> + let emotionPublisher: AnyPublisher } private(set) var output: Output private var routines: [String: [MainRoutine]] = [:] private let nicknameSubject = CurrentValueSubject("") + private let fetchRoutineResultSubject = PassthroughSubject() private let routinesSubject = CurrentValueSubject<[MainRoutine], Never>([]) + private let emotionSubject = CurrentValueSubject(nil) - init() { + private let calendar = Calendar.current + private let today = Date() + private var oldestDate: Date = Date() + private var latestDate: Date = Date() + + private let routineUseCase: RoutineUseCaseProtocol + private let userDataUseCase: UserDataUseCaseProtocol + private let emotionUseCase: EmotionUseCaseProtocol + init( + routineUseCase: RoutineUseCaseProtocol, + userDataUseCase: UserDataUseCaseProtocol, + emotionUseCase: EmotionUseCaseProtocol + ) { + self.routineUseCase = routineUseCase + self.userDataUseCase = userDataUseCase + self.emotionUseCase = emotionUseCase self.output = Output( nicknamePublisher: nicknameSubject.eraseToAnyPublisher(), - routinesPublisher: routinesSubject.eraseToAnyPublisher() + fetchRoutineResultPublisher: fetchRoutineResultSubject.eraseToAnyPublisher(), + routinesPublisher: routinesSubject.eraseToAnyPublisher(), + emotionPublisher: emotionSubject.eraseToAnyPublisher() ) } @@ -42,65 +65,80 @@ final class HomeViewModel: ViewModel { case .fetchDailyRoutines(let date): fetchRoutines(for: date) + + case .fetchEmotion: + fetchEmotion() } } private func loadNickname() { - // TODO: Repository 혹은 UseCase와 연동해야 합니다. - nicknameSubject.send("선영") + Task { + do { + let nickname = try await userDataUseCase.loadNickname() + nicknameSubject.send(nickname) + } catch { + + } + } } private func fetchRoutines() { - // TODO: 서버 통신 로직으로 교체해야 합니다. - let mainRoutine1 = MainRoutine( - id: 1, - startTime: .now, - title: "개운하게 일어나기", - isDone: false, - subRoutines: [ - SubRoutine(id: 1, title: "물 마시기", isDone: true), - SubRoutine(id: 2, title: "물 마시기", isDone: false), - SubRoutine(id: 3, title: "물 마시기", isDone: false) - ]) - - let mainRoutine2 = MainRoutine( - id: 2, - startTime: .now, - title: "떵인이 응원하기", - isDone: false, - subRoutines: []) - - let mainRoutine3 = MainRoutine( - id: 3, - startTime: .now, - title: "아자아자 힘내기", - isDone: false, - subRoutines: [ - SubRoutine(id: 1, title: "힘을내라고말해줄래", isDone: false), - SubRoutine(id: 2, title: "두 눈을 반짝여", isDone: false), - SubRoutine(id: 2, title: "날 일으켜줄래", isDone: false), - ]) - - let mainRoutine4 = MainRoutine( - id: 4, - startTime: .now, - title: "반짝반짝", - isDone: false, - subRoutines: [ - SubRoutine(id: 1, title: "눈이 부셔", isDone: false), - SubRoutine(id: 2, title: "노노노노노노", isDone: false) - ]) - - routines["2025-07-24"] = [mainRoutine1, mainRoutine2, mainRoutine4] - routines["2025-08-02"] = [mainRoutine3] + var startDate = oldestDate + var endDate = latestDate + + if routines.isEmpty { + startDate = calculateDate(for: today, offset: -1) + endDate = calculateDate(for: today, offset: 1) + + oldestDate = startDate + latestDate = endDate + } + + Task { + do { + let entities = try await routineUseCase.fetchRoutines(startDate: startDate, endDate: endDate) + for (date, routineEntities) in entities { + routines[date] = routineEntities.map({ $0.toMainRoutine() }) + } + fetchRoutineResultSubject.send(true) + } catch { + + } + } } private func fetchRoutines(for date: Date) { + if date <= oldestDate { + oldestDate = calendar.date(byAdding: .weekOfYear, value: -1, to: date) ?? date + latestDate = calendar.date(byAdding: .day, value: -1, to: date) ?? date + } else if date >= latestDate { + oldestDate = calendar.date(byAdding: .day, value: 1, to: date) ?? date + latestDate = calendar.date(byAdding: .weekOfYear, value: 1, to: date) ?? date + } + let dateKey = date.convertToString(dateType: .yearMonthDate) guard let dailyRoutines = routines[dateKey] else { - routinesSubject.send([]) + fetchRoutines() return } routinesSubject.send(dailyRoutines) } + + private func fetchEmotion() { + Task { + do { + let emotionEntity = try await emotionUseCase.fetchEmotion(date: today) + let emotion = emotionEntity?.toEmotion() + emotionSubject.send(emotion) + } catch { + + } + } + } + + // 필요 시, 루틴 데이터를 불러옵니다. (+- 주) + private func calculateDate(for date: Date, offset week: Int) -> Date { + let endDate = calendar.date(byAdding: .weekOfYear, value: week, to: date) ?? date + return endDate + } } diff --git a/Projects/Presentation/Sources/Login/Model/TermsType.swift b/Projects/Presentation/Sources/Login/Model/TermsType.swift index 34ba9240..834b75ea 100644 --- a/Projects/Presentation/Sources/Login/Model/TermsType.swift +++ b/Projects/Presentation/Sources/Login/Model/TermsType.swift @@ -19,8 +19,8 @@ extension TermsType { var link: URL? { switch self { - case .service: URL(string: "https://yapp-workspace.notion.site/2282106a0e84804cb283e44f24ecc567") - case .privacy: URL(string: "https://yapp-workspace.notion.site/22a2106a0e848090864dc02fba31de34") + case .service: URL(string: "https://complex-wombat-99f.notion.site/2025-7-20-236f4587491d8071833adfaf8115bce2") + case .privacy: URL(string: "https://complex-wombat-99f.notion.site/2025-07-20-236f4587491d80308016eb810692d18b") case .age: nil } } diff --git a/Projects/Presentation/Sources/Login/View/IntroView.swift b/Projects/Presentation/Sources/Login/View/IntroView.swift index 6ac31676..2422c0e6 100644 --- a/Projects/Presentation/Sources/Login/View/IntroView.swift +++ b/Projects/Presentation/Sources/Login/View/IntroView.swift @@ -14,8 +14,9 @@ public final class IntroView: UIViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 static let labelTopSpacing: CGFloat = 54 + static let labelHeight: CGFloat = 60 static let graphViewTopSpacing: CGFloat = 118 - static let graphViewBottomSpacing: CGFloat = 64 + static let graphViewLeadingSpacing: CGFloat = 53 static let graphViewHeight: CGFloat = 295 static let graphViewWidth: CGFloat = 257 static let startButtonBottomSpacing: CGFloat = 20 @@ -67,14 +68,14 @@ public final class IntroView: UIViewController { make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) make.top.equalTo(safeArea).offset(Layout.labelTopSpacing) - make.height.equalTo(60) + make.height.equalTo(Layout.labelHeight) } graphView.snp.makeConstraints { make in make.top.equalTo(introLabel.snp.bottom).offset(Layout.graphViewTopSpacing) - make.bottom.equalTo(startButton.snp.top).offset(-133) - make.leading.equalTo(safeArea).offset(53) - make.trailing.equalTo(safeArea).inset(65) + make.leading.equalTo(safeArea).offset(Layout.graphViewLeadingSpacing) + make.width.equalTo(Layout.graphViewWidth) + make.height.equalTo(Layout.graphViewHeight) } startButton.snp.makeConstraints { make in diff --git a/Projects/Presentation/Sources/Login/View/LoginView.swift b/Projects/Presentation/Sources/Login/View/LoginView.swift index fbac8fdb..696386ba 100644 --- a/Projects/Presentation/Sources/Login/View/LoginView.swift +++ b/Projects/Presentation/Sources/Login/View/LoginView.swift @@ -15,14 +15,19 @@ final class LoginView: BaseViewController { private enum Layout { static let horizontalMargin: CGFloat = 20 - static let logoTopSpacing: CGFloat = 115 - static let logoSize: CGFloat = 335 + static let loginLabelTopSpacing: CGFloat = 54 + static let loginLabelHeight: CGFloat = 30 + static let logoBottomSpacing: CGFloat = 79 + static let logoLeadingSpacing: CGFloat = 53 + static let logoWidth: CGFloat = 257 + static let logoHeight: CGFloat = 295 static let loginButtonHeight: CGFloat = 54 static let loginButtonBottomSpacing: CGFloat = 20 static let loginButtonSpacing: CGFloat = 12 } - private let logoView = UIView() + private let loginLabel = UILabel() + private let logoView = UIImageView() private let kakaoLoginButton = SocialLoginButton(socialType: .kakao) private let appleLoginButton = SocialLoginButton(socialType: .apple) private var cancellables: Set @@ -46,7 +51,11 @@ final class LoginView: BaseViewController { } override func configureAttribute() { - logoView.backgroundColor = BitnagilColor.gray90 + loginLabel.text = "빛나길에 오신걸 환영해요!" + loginLabel.font = BitnagilFont(style: .title2, weight: .bold).font + loginLabel.textColor = BitnagilColor.navy500 + + logoView.image = BitnagilGraphic.introGraphic kakaoLoginButton.addAction(UIAction { [weak self] _ in self?.viewModel.action(input: .kakaoLogin) @@ -61,15 +70,22 @@ final class LoginView: BaseViewController { let safeArea = view.safeAreaLayoutGuide view.backgroundColor = .systemBackground + view.addSubview(loginLabel) view.addSubview(logoView) view.addSubview(kakaoLoginButton) view.addSubview(appleLoginButton) + loginLabel.snp.makeConstraints { make in + make.top.equalTo(safeArea).offset(Layout.loginLabelTopSpacing) + make.height.equalTo(Layout.loginLabelHeight) + make.centerX.equalToSuperview() + } + logoView.snp.makeConstraints { make in - make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) - make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) - make.top.equalTo(safeArea).offset(Layout.logoTopSpacing) - make.size.equalTo(Layout.logoSize) + make.leading.equalTo(safeArea).offset(Layout.logoLeadingSpacing) + make.bottom.equalTo(kakaoLoginButton.snp.top).offset(-Layout.logoBottomSpacing) + make.width.equalTo(Layout.logoWidth) + make.height.equalTo(Layout.logoHeight) } kakaoLoginButton.snp.makeConstraints { make in diff --git a/Projects/Presentation/Sources/MyPage/ViewModel/MypageViewModel.swift b/Projects/Presentation/Sources/MyPage/ViewModel/MypageViewModel.swift index d91e0ca8..c1d726a7 100644 --- a/Projects/Presentation/Sources/MyPage/ViewModel/MypageViewModel.swift +++ b/Projects/Presentation/Sources/MyPage/ViewModel/MypageViewModel.swift @@ -38,11 +38,13 @@ final class MypageViewModel: ViewModel { nickNamePublisher: nicknamePublisher.eraseToAnyPublisher(), externalURLPublisher: externalURLPublisher.eraseToAnyPublisher()) - do { - let nickname = try userDataRepository.loadNickname() - nicknamePublisher.send(nickname) - } catch { - BitnagilLogger.log(logType: .debug, message: "\(error.localizedDescription)") + Task { + do { + let nickname = try await userDataRepository.loadNickname() + nicknamePublisher.send(nickname) + } catch { + BitnagilLogger.log(logType: .debug, message: "\(error.localizedDescription)") + } } } @@ -58,11 +60,11 @@ final class MypageViewModel: ViewModel { case .resetGoal: break case .notice: // 임시 url - if let url = URL(string: "https://www.google.com") { + if let url = URL(string: "https://complex-wombat-99f.notion.site/23ff4587491d80efa0a5e4baece6017b") { externalURLPublisher.send(url) } case .faq: // 임시 url - if let url = URL(string: "https://www.naver.com") { + if let url = URL(string: "https://complex-wombat-99f.notion.site/23ff4587491d80659ae3ea392afbc05e") { externalURLPublisher.send(url) } } diff --git a/Projects/Presentation/Sources/RoutineCreation/Model/Week.swift b/Projects/Presentation/Sources/RoutineCreation/Model/Week.swift index ab9dd8d6..8169c11a 100644 --- a/Projects/Presentation/Sources/RoutineCreation/Model/Week.swift +++ b/Projects/Presentation/Sources/RoutineCreation/Model/Week.swift @@ -5,14 +5,26 @@ // Created by 이동현 on 7/21/25. // -enum Week: Int, CaseIterable { - case monday - case tuesday - case wednesday - case thursday - case friday - case saturday - case sunday +enum Week: String, CaseIterable { + case monday = "MONDAY" + case tuesday = "TUESDAY" + case wednesday = "WEDNESDAY" + case thursday = "THURSDAY" + case friday = "FRIDAY" + case saturday = "SATURDAY" + case sunday = "SUNDAY" + + var id: Int { + switch self { + case .monday: 0 + case .tuesday: 1 + case .wednesday: 2 + case .thursday: 3 + case .friday: 4 + case .saturday: 5 + case .sunday: 6 + } + } var koreanValue: String { switch self { diff --git a/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationView.swift b/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationView.swift index 66a7df6d..7e7ea106 100644 --- a/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationView.swift +++ b/Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationView.swift @@ -427,7 +427,7 @@ final class RoutineCreationView: BaseViewController { viewModel.output.weekDayPublisher .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] weekDays in - let weekDaysIndex = weekDays.map { $0.rawValue } + let weekDaysIndex = weekDays.map { $0.id } self?.weekdaysStackView .subviews diff --git a/Projects/Presentation/Sources/Common/Extension/Date+.swift b/Projects/Shared/Sources/Extension/Date+.swift similarity index 64% rename from Projects/Presentation/Sources/Common/Extension/Date+.swift rename to Projects/Shared/Sources/Extension/Date+.swift index 69671e98..9ad65abf 100644 --- a/Projects/Presentation/Sources/Common/Extension/Date+.swift +++ b/Projects/Shared/Sources/Extension/Date+.swift @@ -8,7 +8,7 @@ import Foundation extension Date { - func convertToString(dateType: DateType) -> String { + public func convertToString(dateType: DateType) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") formatter.dateFormat = dateType.formatString @@ -16,11 +16,20 @@ extension Date { return formatter.string(from: self) } - enum DateType { + public static func convertToDate(from string: String, dateType: DateType) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = dateType.formatString + + return formatter.date(from: string) + } + + public enum DateType { case yearMonthDate case yearMonth case dayOfWeek case date + case time case amPmTime case amPmTimeShort @@ -30,8 +39,10 @@ extension Date { case .yearMonth: "yyyy년 M월" case .dayOfWeek: "E" case .date: "d" + case .time: "HH:mm:ss" case .amPmTime: "a HH:mm" case .amPmTimeShort: "a h:mm" + } } } diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist index 67c67a8d..2b30b70c 100644 --- a/SupportingFiles/Info.plist +++ b/SupportingFiles/Info.plist @@ -12,6 +12,8 @@ 6.0 CFBundleName 빛나길 + CFBundlePackageType + APPL CFBundleShortVersionString 0.0.1 CFBundleURLTypes