diff --git a/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift b/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift index ffdbcb76..760e3265 100644 --- a/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift +++ b/Projects/DataSource/Sources/DTO/LoginResponseDTO.swift @@ -1,5 +1,5 @@ // -// TokenResponseDTO.swift +// LoginResponseDTO.swift // DataSource // // Created by 최정인 on 6/30/25. diff --git a/Projects/DataSource/Sources/DTO/RoutineDTO.swift b/Projects/DataSource/Sources/DTO/RoutineDTO.swift new file mode 100644 index 00000000..2b0aa82b --- /dev/null +++ b/Projects/DataSource/Sources/DTO/RoutineDTO.swift @@ -0,0 +1,46 @@ +// +// RoutineDTO.swift +// DataSource +// +// Created by 최정인 on 8/19/25. +// + +import Domain + +struct RoutineDictionaryDTO: Decodable { + let routines: [String: RoutineDateDTO] +} + +struct RoutineDateDTO: Decodable { + let routineList: [RoutineDTO] + let allCompleted: Bool +} + +struct RoutineDTO: Decodable { + let routineId: String + let routineName: String + let repeatDay: [String] + let executionTime: String + let routineCompleteYn: Bool + let subRoutineNames: [String] + let subRoutineCompleteYn: [Bool] + let recommendedRoutineType: String? + let routineDeletedYn: Bool + let routineStartDate: String + let routineEndDate: String + + func toRoutineEntity() -> RoutineEntity { + return RoutineEntity( + routineId: routineId, + routineName: routineName, + repeatDay: repeatDay, + executionTime: executionTime, + routineCompleteYn: routineCompleteYn, + subRoutineNames: subRoutineNames, + subRoutineCompleteYn: subRoutineCompleteYn, + recommendedRoutineType: recommendedRoutineType, + routineDeletedYn: routineDeletedYn, + routineStartDate: routineStartDate, + routineEndDate: routineEndDate) + } +} diff --git a/Projects/DataSource/Sources/DTO/RoutineResponseDTO.swift b/Projects/DataSource/Sources/DTO/RoutineResponseDTO.swift deleted file mode 100644 index 3373662e..00000000 --- a/Projects/DataSource/Sources/DTO/RoutineResponseDTO.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// 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 { - let sortedSubRoutinesDTO = subRoutineSearchResultDto.sorted { $0.sortOrder < $1.sortOrder } - - return RoutineEntity( - routineId: routineId, - historySeq: historySeq, - routineName: routineName, - repeatDay: repeatDay, - executionTime: executionTime, - subRoutineSearchResultDto: sortedSubRoutinesDTO.map({ $0.toSubRoutineEntity() }), - modifiedYn: modifiedYn, - routineCompletionId: routineCompletionId, - completeYn: completeYn, - routineType: routineType) - } -} diff --git a/Projects/DataSource/Sources/Endpoint/RoutineEndpoint.swift b/Projects/DataSource/Sources/Endpoint/RoutineEndpoint.swift index e758bc63..c1dc3ff5 100644 --- a/Projects/DataSource/Sources/Endpoint/RoutineEndpoint.swift +++ b/Projects/DataSource/Sources/Endpoint/RoutineEndpoint.swift @@ -11,17 +11,17 @@ enum RoutineEndpoint { case fetchRoutines(startDate: String, endDate: String) case updateRoutine(routine: RoutineCreationDTO) case deleteAllRoutine(routineId: String) - case deleteDailyRoutine(routine: DeleteRoutineDTO) + case deleteDailyRoutine(routineId: String) case updateRoutineCompletion(routines: RoutineCompletionListDTO) } extension RoutineEndpoint: Endpoint { var baseURL: String { switch self { - case .createRoutine, .updateRoutine: - return AppProperties.baseURL + "/api/v2/routines" - default: + case .deleteAllRoutine: return AppProperties.baseURL + "/api/v1/routines" + default: + return AppProperties.baseURL + "/api/v2/routines" } } @@ -31,8 +31,8 @@ extension RoutineEndpoint: Endpoint { "\(baseURL)/\(routineId)" case .deleteAllRoutine(let routineId): "\(baseURL)/\(routineId)" - case .deleteDailyRoutine: - "\(baseURL)/day" + case .deleteDailyRoutine(let routineId): + "\(baseURL)/day/\(routineId)" case .updateRoutineCompletion: "\(baseURL)/completions" default: @@ -78,8 +78,6 @@ extension RoutineEndpoint: Endpoint { return routine.dictionary case .updateRoutine(let routine): return routine.dictionary - case .deleteDailyRoutine(let routine): - return routine.dictionary case .updateRoutineCompletion(let routines): return routines.dictionary default: diff --git a/Projects/DataSource/Sources/Repository/RoutineRepository.swift b/Projects/DataSource/Sources/Repository/RoutineRepository.swift index d7ba2bc6..e6007d27 100644 --- a/Projects/DataSource/Sources/Repository/RoutineRepository.swift +++ b/Projects/DataSource/Sources/Repository/RoutineRepository.swift @@ -29,23 +29,25 @@ final class RoutineRepository: RoutineRepositoryProtocol { func fetchRoutine(routineId: String) async throws -> RoutineEntity? { let endpoint = RoutineEndpoint.fetchRoutine(routineId: routineId) - guard let response = try await networkService.request(endpoint: endpoint, type: RoutineResponseDTO.self) else { return nil } + guard let response = try await networkService.request(endpoint: endpoint, type: RoutineDTO.self) else { return nil } return response.toRoutineEntity() } - func fetchRoutines(from startDate: String, to endDate: String) async throws -> [String: [RoutineEntity]] { + func fetchRoutines(from startDate: String, to endDate: String) async throws -> [String: (routines: [RoutineEntity], allCompleted: Bool)] { 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]] = [:] + var result: [String: ([RoutineEntity], Bool)] = [:] for (date, routineDTO) in response.routines { - result[date] = routineDTO.compactMap({ $0.toRoutineEntity() }) + let allCompleted = routineDTO.allCompleted + let routines = routineDTO.routineList.compactMap({ $0.toRoutineEntity() }) + result[date] = (routines, allCompleted) } return result } - + func updateRoutine(routine: RoutineCreationEntity) async throws { let routineUpdateDTO = RoutineCreationDTO( routineId: routine.id, @@ -67,20 +69,8 @@ final class RoutineRepository: RoutineRepositoryProtocol { _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) } - func deleteDailyRoutine(routine: DeleteRoutineEntity) async throws { - let deleteSubRoutineDTO = routine - .subRoutineInfosForDelete - .map({ DeleteSubRoutineDTO(subRoutineId: $0.subRoutineId, routineCompletionId: $0.routineCompletionId) }) - - let deleteRoutineDTO = DeleteRoutineDTO( - routineId: routine.routineId, - routineCompletionId: routine.routineCompletionId, - historySeq: routine.historySeq, - routineType: routine.routineType, - performedDate: routine.performedDate, - subRoutineInfosForDelete: deleteSubRoutineDTO) - - let endpoint = RoutineEndpoint.deleteDailyRoutine(routine: deleteRoutineDTO) + func deleteDailyRoutine(routineId: String) async throws { + let endpoint = RoutineEndpoint.deleteDailyRoutine(routineId: routineId) _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self) } diff --git a/Projects/Domain/Sources/Entity/DeleteRoutineEntity.swift b/Projects/Domain/Sources/Entity/DeleteRoutineEntity.swift deleted file mode 100644 index a0a820e7..00000000 --- a/Projects/Domain/Sources/Entity/DeleteRoutineEntity.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// DeleteRoutineEntity.swift -// Domain -// -// Created by 최정인 on 8/4/25. -// - -public struct DeleteRoutineEntity: Encodable { - public let routineId: String - public let routineCompletionId: Int? - public let historySeq: Int - public let performedDate: String - public let routineType: String - public let subRoutineInfosForDelete: [DeleteSubRoutineEntity] - - public init( - routineId: String, - routineCompletionId: Int?, - historySeq: Int, - performedDate: String, - routineType: String, - subRoutineInfosForDelete: [DeleteSubRoutineEntity] - ) { - self.routineId = routineId - self.routineCompletionId = routineCompletionId - self.historySeq = historySeq - self.performedDate = performedDate - self.routineType = routineType - self.subRoutineInfosForDelete = subRoutineInfosForDelete - } -} - -public struct DeleteSubRoutineEntity: Encodable { - public let subRoutineId: String - public let routineCompletionId: Int? - - public init(subRoutineId: String, routineCompletionId: Int?) { - self.subRoutineId = subRoutineId - self.routineCompletionId = routineCompletionId - } -} diff --git a/Projects/Domain/Sources/Entity/RoutineEntity.swift b/Projects/Domain/Sources/Entity/RoutineEntity.swift index 2b8b4915..edd862f1 100644 --- a/Projects/Domain/Sources/Entity/RoutineEntity.swift +++ b/Projects/Domain/Sources/Entity/RoutineEntity.swift @@ -2,44 +2,45 @@ // RoutineEntity.swift // Domain // -// Created by 최정인 on 7/30/25. +// Created by 최정인 on 8/19/25. // public struct RoutineEntity { - public let routineId: String? - public let historySeq: Int + public let routineId: String public let routineName: String - public let repeatDay: [Week] + 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 let routineCompleteYn: Bool + public let subRoutineNames: [String] + public let subRoutineCompleteYn: [Bool] + public let recommendedRoutineType: String? + public let routineDeletedYn: Bool + public let routineStartDate: String + public let routineEndDate: String public init( - routineId: String?, - historySeq: Int, + routineId: String, routineName: String, - repeatDay: [String]?, + repeatDay: [String], executionTime: String, - subRoutineSearchResultDto: [SubRoutineEntity], - modifiedYn: Bool, - routineCompletionId: Int?, - completeYn: Bool, - routineType: String + routineCompleteYn: Bool, + subRoutineNames: [String], + subRoutineCompleteYn: [Bool], + recommendedRoutineType: String?, + routineDeletedYn: Bool, + routineStartDate: String, + routineEndDate: String ) { - let weekType: [Week] = repeatDay?.compactMap(Week.init(rawValue:)) ?? [] - self.routineId = routineId - self.historySeq = historySeq self.routineName = routineName - self.repeatDay = weekType + self.repeatDay = repeatDay self.executionTime = executionTime - self.subRoutineSearchResultDto = subRoutineSearchResultDto - self.modifiedYn = modifiedYn - self.routineCompletionId = routineCompletionId - self.completeYn = completeYn - self.routineType = routineType + self.routineCompleteYn = routineCompleteYn + self.subRoutineNames = subRoutineNames + self.subRoutineCompleteYn = subRoutineCompleteYn + self.recommendedRoutineType = recommendedRoutineType + self.routineDeletedYn = routineDeletedYn + self.routineStartDate = routineStartDate + self.routineEndDate = routineEndDate } } diff --git a/Projects/Domain/Sources/Protocol/Repository/RoutineRepositoryProtocol.swift b/Projects/Domain/Sources/Protocol/Repository/RoutineRepositoryProtocol.swift index cba984d8..7b7d3e5d 100644 --- a/Projects/Domain/Sources/Protocol/Repository/RoutineRepositoryProtocol.swift +++ b/Projects/Domain/Sources/Protocol/Repository/RoutineRepositoryProtocol.swift @@ -21,7 +21,7 @@ public protocol RoutineRepositoryProtocol { /// - Parameters: /// - startDate: 조회 시작 날짜 /// - endDate: 조회 종료 날짜 - func fetchRoutines(from startDate: String, to endDate: String) async throws -> [String: [RoutineEntity]] + func fetchRoutines(from startDate: String, to endDate: String) async throws -> [String: (routines: [RoutineEntity], allCompleted: Bool)] /// 루틴을 수정합니다. /// - Parameters: @@ -34,7 +34,7 @@ public protocol RoutineRepositoryProtocol { /// 당일 루틴을 삭제합니다. /// - Parameter routine: 삭제할 루틴 정보 - func deleteDailyRoutine(routine: DeleteRoutineEntity) async throws + func deleteDailyRoutine(routineId: String) async throws /// 루틴 완료 여부를 업데이트 합니다. /// - Parameter routines: 완료 여부를 업데이트할 루틴 배열 diff --git a/Projects/Domain/Sources/Protocol/UseCase/RoutineUseCaseProtocol.swift b/Projects/Domain/Sources/Protocol/UseCase/RoutineUseCaseProtocol.swift index 0ef60c8b..51a86444 100644 --- a/Projects/Domain/Sources/Protocol/UseCase/RoutineUseCaseProtocol.swift +++ b/Projects/Domain/Sources/Protocol/UseCase/RoutineUseCaseProtocol.swift @@ -10,13 +10,13 @@ import Foundation public protocol RoutineUseCaseProtocol { func fetchRoutine(routineId: String) async throws -> RoutineEntity? - func fetchRoutines(startDate: Date, endDate: Date) async throws -> [String: [RoutineEntity]] + func fetchRoutines(startDate: Date, endDate: Date) async throws -> [String: (routines: [RoutineEntity], allCompleted: Bool)] func saveRoutine(routine: RoutineCreationEntity) async throws func deleteAllRoutine(routineId: String) async throws - func deleteDailyRoutine(routine: DeleteRoutineEntity) async throws + func deleteDailyRoutine(routineId: String) async throws func updateRoutineCompletions(routines: [RoutineCompletionEntity]) async throws } diff --git a/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift b/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift index 13c21479..2fcd9822 100644 --- a/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift @@ -20,7 +20,7 @@ public final class RoutineUseCase: RoutineUseCaseProtocol { return routineEnity } - public func fetchRoutines(startDate: Date, endDate: Date) async throws -> [String: [RoutineEntity]] { + public func fetchRoutines(startDate: Date, endDate: Date) async throws -> [String: (routines: [RoutineEntity], allCompleted: Bool)] { let start = startDate.convertToString(dateType: .yearMonthDate) let end = endDate.convertToString(dateType: .yearMonthDate) @@ -59,8 +59,8 @@ public final class RoutineUseCase: RoutineUseCaseProtocol { try await routineRepository.deleteAllRoutine(routineId: routineId) } - public func deleteDailyRoutine(routine: DeleteRoutineEntity) async throws { - try await routineRepository.deleteDailyRoutine(routine: routine) + public func deleteDailyRoutine(routineId: String) async throws { + try await routineRepository.deleteDailyRoutine(routineId: routineId) } public func updateRoutineCompletions(routines: [RoutineCompletionEntity]) async throws { diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/Contents.json similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/Contents.json diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/default_emotion_graphic.png similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic.png rename to Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/default_emotion_graphic.png diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@2x.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/default_emotion_graphic@2x.png similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@2x.png rename to Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/default_emotion_graphic@2x.png diff --git a/Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@3x.png b/Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/default_emotion_graphic@3x.png similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/default_emotion_graphic.imageset/default_emotion_graphic@3x.png rename to Projects/Presentation/Resources/Images.xcassets/Graphic/default_emotion_graphic.imageset/default_emotion_graphic@3x.png diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/Contents.json b/Projects/Presentation/Resources/Images.xcassets/RoutineList/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Presentation/Resources/Images.xcassets/RoutineList/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/Contents.json similarity index 69% rename from Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/Contents.json index 1dd6a534..420320fa 100644 --- a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/Contents.json +++ b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "repeat_icon.png", + "filename" : "close_icon.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "repeat_icon@2x.png", + "filename" : "close_icon@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "repeat_icon@3x.png", + "filename" : "close_icon@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon.png new file mode 100644 index 00000000..20b2870c Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon@2x.png new file mode 100644 index 00000000..f4241c74 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon@3x.png new file mode 100644 index 00000000..4fa20eb0 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/close_icon.imageset/close_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/Contents.json similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/Contents.json diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon.png new file mode 100644 index 00000000..8e714a19 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon@2x.png new file mode 100644 index 00000000..0dd11455 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon@3x.png new file mode 100644 index 00000000..e366f196 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/edit_icon.imageset/edit_icon@3x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/Contents.json similarity index 100% rename from Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/Contents.json rename to Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/Contents.json diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon.png new file mode 100644 index 00000000..74596437 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon@2x.png new file mode 100644 index 00000000..63509cd4 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon@2x.png differ diff --git a/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon@3x.png new file mode 100644 index 00000000..6180e0a9 Binary files /dev/null and b/Projects/Presentation/Resources/Images.xcassets/RoutineList/trash_icon.imageset/trash_icon@3x.png differ 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 deleted file mode 100644 index 78b83c43..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon.png and /dev/null 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 deleted file mode 100644 index 73e479e7..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@2x.png and /dev/null 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 deleted file mode 100644 index 9f7ca84b..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/edit_icon.imageset/edit_icon@3x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/Contents.json b/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/Contents.json deleted file mode 100644 index 533d5aee..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "ellipsis_icon.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "ellipsis_icon@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "ellipsis_icon@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon.png b/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon.png deleted file mode 100644 index 90edad04..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon@2x.png b/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon@2x.png deleted file mode 100644 index 8bd0a183..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon@2x.png and /dev/null differ diff --git a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon@3x.png b/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon@3x.png deleted file mode 100644 index 2657b439..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/ellipsis_icon.imageset/ellipsis_icon@3x.png and /dev/null differ 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 deleted file mode 100644 index 995bf98a..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon.png and /dev/null 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 deleted file mode 100644 index 5f9f280f..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@2x.png and /dev/null 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 deleted file mode 100644 index 85e83d6f..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/repeat_icon.imageset/repeat_icon@3x.png and /dev/null 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 deleted file mode 100644 index 6ffa164e..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index 934b8f7b..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon.png and /dev/null 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 deleted file mode 100644 index 86d90524..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@2x.png and /dev/null 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 deleted file mode 100644 index 4f548f21..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/routine_icon.imageset/routine_icon@3x.png and /dev/null 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 deleted file mode 100644 index a4801648..00000000 --- a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index 87eef327..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon.png and /dev/null 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 deleted file mode 100644 index cfb5731a..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@2x.png and /dev/null 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 deleted file mode 100644 index 6a553062..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/subRoutine_icon.imageset/subRoutine_icon@3x.png and /dev/null differ 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 deleted file mode 100644 index ee5a739d..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon.png and /dev/null 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 deleted file mode 100644 index 7b715c16..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@2x.png and /dev/null 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 deleted file mode 100644 index 96099609..00000000 Binary files a/Projects/Presentation/Resources/Images.xcassets/trash_icon.imageset/trash_icon@3x.png and /dev/null differ diff --git a/Projects/Presentation/Sources/Common/Component/RoutineCardView.swift b/Projects/Presentation/Sources/Common/Component/RoutineCardView.swift index 680cf48a..40a4b9ed 100644 --- a/Projects/Presentation/Sources/Common/Component/RoutineCardView.swift +++ b/Projects/Presentation/Sources/Common/Component/RoutineCardView.swift @@ -11,6 +11,8 @@ import UIKit protocol RoutineCardViewDelegate: AnyObject { func routineCardView(_ sender: RoutineCardView, didTapPlusButton routine: RecommendedRoutine) + func routineCardView(_ sender: RoutineCardView, didTapEditButton routine: Routine) + func routineCardView(_ sender: RoutineCardView, didTapDeleteButton routine: Routine) } final class RoutineCardView: UIView { @@ -19,14 +21,15 @@ final class RoutineCardView: UIView { static let cornerRadius: CGFloat = 12 static let headerInfoStackViewSpacing: CGFloat = 10 static let headerInfoStackViewTopSpacing: CGFloat = 14 - static let subRoutineStackViewSpacing: CGFloat = 2 - static let subRoutineStackViewTopSpacing: CGFloat = 10 - static let subRoutineStackViewBottomSpacing: CGFloat = 14 - static let subRoutineLabelHeight: CGFloat = 20 + static let routineInfoStackViewVerticalMargin: CGFloat = 14 + static let routineInfoStackViewSpacing: CGFloat = 10 + static let stackViewSpacing: CGFloat = 2 + static let subLabelHeight: CGFloat = 20 static let categoryIconSize: CGFloat = 32 static let plusImageSize: CGFloat = 24 static let plusButtonTopSpacing: CGFloat = 14 - static let plusButtonTrailingSpacing: CGFloat = 7 + static let buttonTrailingSpacing: CGFloat = 7 + static let editButtonTrailingSpacing: CGFloat = 40 static let plusButtonSize: CGFloat = 32 static let grayLineTopSpacing: CGFloat = 10 static let grayLineHeight: CGFloat = 1 @@ -38,13 +41,16 @@ final class RoutineCardView: UIView { private let editButton = UIButton() private let deleteButton = UIButton() private let plusButton = UIButton() + private let routineInfoStackView = UIStackView() private let grayLine = UIView() private let subRoutineLabel = UILabel() private let subRoutineStackView = UIStackView() + private let grayLine2 = UIView() + private let infoStackView = UIStackView() - private let routine: RecommendedRoutine + private let routine: RoutineProtocol weak var delegate: RoutineCardViewDelegate? - init(routine: RecommendedRoutine) { + init(routine: RoutineProtocol) { self.routine = routine super.init(frame: .zero) configureAttribute() @@ -63,42 +69,122 @@ final class RoutineCardView: UIView { headerInfoStackView.axis = .horizontal headerInfoStackView.spacing = Layout.headerInfoStackViewSpacing - titleLabel.text = routine.mainTitle + titleLabel.text = routine.title titleLabel.font = BitnagilFont(style: .body1, weight: .semiBold).font titleLabel.textColor = BitnagilColor.gray10 + editButton.setImage(BitnagilIcon.editIcon, for: .normal) + editButton.addAction( + UIAction { [weak self] _ in + guard + let self, + let routine = self.routine as? Routine + else { return } + self.delegate?.routineCardView(self, didTapEditButton: routine) + }, + for: .touchUpInside) + + deleteButton.setImage(BitnagilIcon.trashIcon, for: .normal) + deleteButton.addAction( + UIAction { [weak self] _ in + guard + let self, + let routine = self.routine as? Routine + else { return } + self.delegate?.routineCardView(self, didTapDeleteButton: routine) + }, + for: .touchUpInside) + let plusImage = BitnagilIcon.plusIcon? .resizeAspectFit(to: CGSize(width: Layout.plusImageSize, height: Layout.plusImageSize)) plusButton.setImage(plusImage, for: .normal) plusButton.tintColor = BitnagilColor.gray10 plusButton.addAction( UIAction { [weak self] _ in - guard let self else { return } - delegate?.routineCardView(self, didTapPlusButton: routine) - }, for: .touchUpInside) + guard + let self, + let routine = self.routine as? RecommendedRoutine + else { return } + self.delegate?.routineCardView(self, didTapPlusButton: routine) + }, + for: .touchUpInside) + + routineInfoStackView.axis = .vertical + routineInfoStackView.spacing = Layout.routineInfoStackViewSpacing grayLine.backgroundColor = BitnagilColor.gray97 + grayLine2.backgroundColor = BitnagilColor.gray97 subRoutineStackView.axis = .vertical - subRoutineStackView.spacing = Layout.subRoutineStackViewSpacing + subRoutineStackView.spacing = Layout.stackViewSpacing + + if !routine.subRoutines.isEmpty { + subRoutineLabel.text = "세부 루틴" + subRoutineLabel.font = BitnagilFont(style: .body2, weight: .medium).font + subRoutineLabel.textColor = BitnagilColor.gray40 + subRoutineStackView.addArrangedSubview(subRoutineLabel) + subRoutineLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.subLabelHeight) + } - subRoutineLabel.text = "세부 루틴" - subRoutineLabel.font = BitnagilFont(style: .body2, weight: .medium).font - subRoutineLabel.textColor = BitnagilColor.gray40 - subRoutineLabel.snp.makeConstraints { make in - make.height.equalTo(Layout.subRoutineLabelHeight) + routine.subRoutines.forEach { + let subRoutineTitleLabel = UILabel() + subRoutineTitleLabel.text = "• \($0)" + subRoutineTitleLabel.font = BitnagilFont(style: .body2, weight: .medium).font + subRoutineTitleLabel.textColor = BitnagilColor.gray40 + subRoutineStackView.addArrangedSubview(subRoutineTitleLabel) + subRoutineTitleLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.subLabelHeight) + } + } + } else { + grayLine.isHidden = true + subRoutineStackView.isHidden = true } - subRoutineStackView.addArrangedSubview(subRoutineLabel) - - routine.subRoutines.forEach { - let subRoutineTitleLabel = UILabel() - subRoutineTitleLabel.text = "• \($0)" - subRoutineTitleLabel.font = BitnagilFont(style: .body2, weight: .medium).font - subRoutineTitleLabel.textColor = BitnagilColor.gray40 - subRoutineTitleLabel.snp.makeConstraints { make in - make.height.equalTo(Layout.subRoutineLabelHeight) + + if let mainRoutine = routine as? Routine { + infoStackView.axis = .vertical + infoStackView.spacing = Layout.stackViewSpacing + + plusButton.isHidden = true + + // 반복 주기 + let repeatDayText = mainRoutine.repeatDay.isEmpty ? "X" : mainRoutine.repeatDay.map({ $0.koreanValue }).joined(separator: ", ") + let repeatDayLabel = UILabel() + repeatDayLabel.text = "반복: \(repeatDayText)" + repeatDayLabel.font = BitnagilFont(style: .body2, weight: .medium).font + repeatDayLabel.textColor = BitnagilColor.gray40 + infoStackView.addArrangedSubview(repeatDayLabel) + repeatDayLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.subLabelHeight) + } + + // 기간 + let periodLabel = UILabel() + let startDate = mainRoutine.startDate.convertToString(dateType: .yearMonthDateShort) + let endDate = mainRoutine.endDate.convertToString(dateType: .yearMonthDateShort) + let periodText = startDate == endDate ? "X" : "\(startDate) - \(endDate)" + periodLabel.text = "기간: \(periodText)" + periodLabel.font = BitnagilFont(style: .body2, weight: .medium).font + periodLabel.textColor = BitnagilColor.gray40 + infoStackView.addArrangedSubview(periodLabel) + periodLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.subLabelHeight) + } + + // 시간 + let timeLabel = UILabel() + let textTime = mainRoutine.startTime.convertToString(dateType: .time24hour) == "00:00" ? "하루종일" : mainRoutine.startTime.convertToString(dateType: .amPmTime) + timeLabel.text = "시간: \(textTime)" + timeLabel.font = BitnagilFont(style: .body2, weight: .medium).font + timeLabel.textColor = BitnagilColor.gray40 + infoStackView.addArrangedSubview(timeLabel) + timeLabel.snp.makeConstraints { make in + make.height.equalTo(Layout.subLabelHeight) } - subRoutineStackView.addArrangedSubview(subRoutineTitleLabel) + } else { + grayLine2.isHidden = true + infoStackView.isHidden = true } } @@ -108,8 +194,10 @@ final class RoutineCardView: UIView { } addSubview(headerInfoStackView) addSubview(plusButton) - addSubview(grayLine) - addSubview(subRoutineStackView) + [grayLine, subRoutineStackView, grayLine2, infoStackView].forEach { + routineInfoStackView.addArrangedSubview($0) + } + addSubview(routineInfoStackView) categoryIconView.snp.makeConstraints { make in make.size.equalTo(Layout.categoryIconSize) @@ -122,22 +210,45 @@ final class RoutineCardView: UIView { plusButton.snp.makeConstraints { make in make.top.equalToSuperview().offset(Layout.plusButtonTopSpacing) - make.trailing.equalToSuperview().inset(Layout.plusButtonTrailingSpacing) + make.trailing.equalToSuperview().offset(-Layout.buttonTrailingSpacing) make.size.equalTo(Layout.plusButtonSize) } - grayLine.snp.makeConstraints { make in - make.top.equalTo(headerInfoStackView.snp.bottom).offset(Layout.grayLineTopSpacing) - make.leading.equalToSuperview().offset(Layout.horizontalMargin) - make.trailing.equalToSuperview().inset(Layout.horizontalMargin) - make.height.equalTo(Layout.grayLineHeight) + [grayLine, grayLine2].forEach { view in + view.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Layout.grayLineHeight) + } + } + + [subRoutineStackView, infoStackView].forEach { view in + view.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview() + } } - subRoutineStackView.snp.makeConstraints { make in - make.top.equalTo(grayLine.snp.bottom).offset(Layout.subRoutineStackViewTopSpacing) + routineInfoStackView.snp.makeConstraints { make in + make.top.equalTo(headerInfoStackView.snp.bottom).offset(Layout.routineInfoStackViewVerticalMargin) make.leading.equalToSuperview().offset(Layout.horizontalMargin) make.trailing.equalToSuperview().inset(Layout.horizontalMargin) - make.bottom.equalToSuperview().inset(Layout.subRoutineStackViewBottomSpacing) + make.bottom.equalToSuperview().inset(Layout.routineInfoStackViewVerticalMargin) + } + + if let _ = routine as? Routine { + addSubview(editButton) + addSubview(deleteButton) + + editButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.plusButtonTopSpacing) + make.trailing.equalTo(deleteButton).offset(-Layout.editButtonTrailingSpacing) + make.size.equalTo(Layout.plusButtonSize) + } + + deleteButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.plusButtonTopSpacing) + make.trailing.equalToSuperview().inset(Layout.buttonTrailingSpacing) + make.size.equalTo(Layout.plusButtonSize) + } } } } @@ -149,8 +260,8 @@ fileprivate class RoutineCategoryIcon: UIView { } private let routineCategoryIcon = UIImageView() - private let routineCategory: RoutineCategoryType - init(routineCategory: RoutineCategoryType) { + private let routineCategory: RoutineCategoryType? + init(routineCategory: RoutineCategoryType?) { self.routineCategory = routineCategory super.init(frame: .zero) configureAttribute() @@ -164,8 +275,8 @@ fileprivate class RoutineCategoryIcon: UIView { private func configureAttribute() { layer.masksToBounds = true layer.cornerRadius = Layout.cornerRadius - backgroundColor = routineCategory.iconBackgroundColor ?? BitnagilColor.yellow10 - routineCategoryIcon.image = routineCategory.iconImage ?? BitnagilIcon.shineIcon + backgroundColor = routineCategory?.iconBackgroundColor ?? BitnagilColor.yellow10 + routineCategoryIcon.image = routineCategory?.iconImage ?? BitnagilIcon.shineIcon } private func configureLayout() { diff --git a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift index 4cac7326..4210250a 100644 --- a/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift +++ b/Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift @@ -45,7 +45,6 @@ enum BitnagilIcon { static func chevronIcon(direction: Direction) -> UIImage? { return BitnagilIcon.chevronIcon?.rotate(degrees: direction.rotation)?.withRenderingMode(.alwaysTemplate) } - static let ellipsisIcon = UIImage(named: "ellipsis_icon", in: bundle, with: nil) static let informationIcon = UIImage(named: "information_icon", in: bundle, with: nil) static let addRoutineIcon = UIImage(named: "add_routine_icon", in: bundle, with: nil) static let divideLineIcon = UIImage(named: "divide_line_icon", in: bundle, with: nil) @@ -62,8 +61,8 @@ enum BitnagilIcon { // MARK: - Tab Bar Icons static let homeIcon = UIImage(named: "home_fill_icon", in: bundle, with: nil) static let recommendIcon = UIImage(named: "recommend_fill_icon",in: bundle, with: nil) - static let reportFillIcon = UIImage(named: "report_fill_icon", in: bundle, with: nil)?.withRenderingMode(.alwaysOriginal) - static let reportEmptyIcon = UIImage(named: "report_empty_icon", in: bundle, with: nil)?.withRenderingMode(.alwaysOriginal) + static let reportFillIcon = UIImage(named: "report_fill_icon", in: bundle, with: nil) + static let reportEmptyIcon = UIImage(named: "report_empty_icon", in: bundle, with: nil) static let mypageIcon = UIImage(named: "mypage_fill_icon", in: bundle, with: nil) // MARK: - Mypage Icons @@ -76,13 +75,6 @@ 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) static let routineTimeIcon = UIImage(named: "routine_creation_time_icon", in: bundle, with: nil) static let routineListIcon = UIImage(named: "routine_creation_list_icon", in: bundle, with: nil) static let routineDateIcon = UIImage(named: "routine_creation_date", in: bundle, with: nil) @@ -90,6 +82,11 @@ enum BitnagilIcon { static let oneIcon = UIImage(named: "routine_creation_one_icon", in: bundle, with: nil) static let twoIcon = UIImage(named: "routine_creation_two_icon", in: bundle, with: nil) static let threeIcon = UIImage(named: "routine_creation_three_icon", in: bundle, with: nil) + + // MARK: - Routine List Icons + static let editIcon = UIImage(named: "edit_icon", in: bundle, with: nil) + static let trashIcon = UIImage(named: "trash_icon", in: bundle, with: nil) + static let closeIcon = UIImage(named: "close_icon", in: bundle, with: nil) } enum Direction { diff --git a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift index 8b111b52..07127f57 100644 --- a/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift +++ b/Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift @@ -98,5 +98,12 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol { return IntroViewModel(userDataRepository: userDataRepository) } + + DIContainer.shared.register(type: RoutineListViewModel.self) { container in + guard let routineRepository = container.resolve(type: RoutineRepositoryProtocol.self) + else { fatalError("routineRepository 의존성이 등록되지 않았습니다.") } + + return RoutineListViewModel(routineRepository: routineRepository) + } } } diff --git a/Projects/Presentation/Sources/Common/Protocol/RoutineProtocol.swift b/Projects/Presentation/Sources/Common/Protocol/RoutineProtocol.swift new file mode 100644 index 00000000..b15996b8 --- /dev/null +++ b/Projects/Presentation/Sources/Common/Protocol/RoutineProtocol.swift @@ -0,0 +1,14 @@ +// +// RoutineProtocol.swift +// Presentation +// +// Created by 최정인 on 8/20/25. +// + +import Domain + +protocol RoutineProtocol { + var title: String { get } + var routineType: RoutineCategoryType? { get } + var subRoutines: [String] { get } +} diff --git a/Projects/Presentation/Sources/Home/Model/MainRoutine.swift b/Projects/Presentation/Sources/Home/Model/MainRoutine.swift deleted file mode 100644 index ab101f35..00000000 --- a/Projects/Presentation/Sources/Home/Model/MainRoutine.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// MainRoutine.swift -// Presentation -// -// Created by 최정인 on 7/18/25. -// - -import Domain -import Foundation - -struct MainRoutine: Routine { - let id: String - let title: String - var isDone: Bool - let startTime: Date - let repeatDay: [Week] - var subRoutines: [SubRoutine] - let historySeq: Int - let completionId: Int? - let routineType: String -} - -extension RoutineEntity { - func toMainRoutine() -> MainRoutine? { - guard let routineId else { return nil } - - let subRoutines = subRoutineSearchResultDto.compactMap { $0.toSubRoutine() } - let isAllConverted = subRoutines.count == subRoutineSearchResultDto.count - guard isAllConverted else { return nil } - - return MainRoutine( - id: routineId, - title: routineName, - isDone: completeYn, - startTime: Date.convertToDate(from: executionTime, dateType: .time) ?? Date(), - repeatDay: repeatDay.compactMap({ Week(rawValue: $0.rawValue) }), - subRoutines: subRoutines, - historySeq: historySeq, - completionId: routineCompletionId, - routineType: routineType) - } -} diff --git a/Projects/Presentation/Sources/Home/Model/Routine.swift b/Projects/Presentation/Sources/Home/Model/Routine.swift index 11041b87..f064a554 100644 --- a/Projects/Presentation/Sources/Home/Model/Routine.swift +++ b/Projects/Presentation/Sources/Home/Model/Routine.swift @@ -2,12 +2,39 @@ // Routine.swift // Presentation // -// Created by 최정인 on 8/6/25. +// Created by 최정인 on 8/20/25. // -protocol Routine { - var id: String { get } - var isDone: Bool { get } - var routineType: String { get } - var historySeq: Int { get } +import Domain +import Foundation + +struct Routine: RoutineProtocol { + let id: String + let title: String + var isDone: Bool + let startTime: Date + let repeatDay: [Week] + var subRoutines: [String] + var subRoutineCompleted: [Bool] + let routineType: RoutineCategoryType? + let isDeleted: Bool + let startDate: Date + let endDate: Date +} + +extension RoutineEntity { + func toRoutine() -> Routine { + return Routine( + id: routineId, + title: routineName, + isDone: routineCompleteYn, + startTime: Date.convertToDate(from: executionTime, dateType: .time) ?? Date(), + repeatDay: repeatDay.map({ Week(rawValue: $0) ?? .monday }), + subRoutines: subRoutineNames, + subRoutineCompleted: subRoutineCompleteYn, + routineType: RoutineCategoryType(rawValue: recommendedRoutineType ?? ""), + isDeleted: routineDeletedYn, + startDate: Date.convertToDate(from: routineStartDate, dateType: .yearMonthDate) ?? Date(), + endDate: Date.convertToDate(from: routineEndDate, dateType: .yearMonthDate) ?? Date()) + } } diff --git a/Projects/Presentation/Sources/Home/Model/SubRoutine.swift b/Projects/Presentation/Sources/Home/Model/SubRoutine.swift deleted file mode 100644 index e30a48ea..00000000 --- a/Projects/Presentation/Sources/Home/Model/SubRoutine.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// SubRoutine.swift -// Presentation -// -// Created by 최정인 on 7/18/25. -// - -import Domain - -struct SubRoutine: Hashable, Routine { - let id: String - let title: String - var isDone: Bool - let sortIndex: Int - let completionId: Int? - let routineType: String - let historySeq: Int -} - -extension SubRoutineEntity { - func toSubRoutine() -> SubRoutine? { - guard let subRoutineId else { return nil } - - return SubRoutine( - id: subRoutineId, - title: subRoutineName, - isDone: completeYn, - sortIndex: sortOrder, - completionId: routineCompletionId, - routineType: routineType, - historySeq: historySeq) - } -} diff --git a/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift b/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift index 116b2163..7cc644cf 100644 --- a/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift +++ b/Projects/Presentation/Sources/Home/View/Component/RoutineView.swift @@ -9,11 +9,6 @@ import Shared import SnapKit import UIKit -protocol RoutineViewDelegate: AnyObject { - func routineView(_ sender: RoutineView, didTapMainRoutineCheckButton mainRoutine: MainRoutine) - func routineView(_ sender: RoutineView, didTapSubRoutineCheckButton subRoutine: SubRoutine) -} - final class RoutineView: UIView { private enum Layout { static let timeLabelHeight: CGFloat = 20 @@ -29,13 +24,13 @@ final class RoutineView: UIView { private var isLayoutConfigured: Bool = false private var mainRoutineHeightConstraint: Constraint? - private var routine: MainRoutine { + private var routine: Routine { didSet { updateRoutineState() } } - weak var delegate: RoutineViewDelegate? - init(routine: MainRoutine) { + + init(routine: Routine) { self.routine = routine super.init(frame: .zero) configureAttribute() @@ -82,7 +77,6 @@ final class RoutineView: UIView { var updatedRoutine = routine updatedRoutine.isDone.toggle() self.routine = updatedRoutine - delegate?.routineView(self, didTapMainRoutineCheckButton: routine) }, for: .touchUpInside) @@ -140,7 +134,7 @@ final class RoutineView: UIView { make.height.equalTo(1) } - for subRoutine in routine.subRoutines { + for subRoutine in zip(routine.subRoutines, routine.subRoutineCompleted) { let subRoutineView = makeSubRoutineView(subRoutine: subRoutine) subRoutineView.snp.makeConstraints { make in make.height.equalTo(24) @@ -155,7 +149,7 @@ final class RoutineView: UIView { } } - private func makeSubRoutineView(subRoutine: SubRoutine) -> UIView { + private func makeSubRoutineView(subRoutine: (title: String, isDone: Bool)) -> UIView { let subRoutineView = UIView() let checkButton = UIButton() let subRoutineLabel = UILabel() diff --git a/Projects/Presentation/Sources/Home/View/HomeView.swift b/Projects/Presentation/Sources/Home/View/HomeView.swift index c380a656..1f13c0bb 100644 --- a/Projects/Presentation/Sources/Home/View/HomeView.swift +++ b/Projects/Presentation/Sources/Home/View/HomeView.swift @@ -86,7 +86,6 @@ final class HomeView: BaseViewController { private let dimmedView = UIView() private let floatingButton = FloatingButton() private let floatingMenu = FloatingMenuView() - private var bottomSheet: CustomBottomSheet? private var contentViewTopConstraint: Constraint? private var cancellables: Set @@ -179,6 +178,10 @@ final class HomeView: BaseViewController { BitnagilFont(style: .caption1, weight: .semiBold).attributedString(text: "더보기"), for: .normal) routineListButton.setTitleColor(BitnagilColor.gray10, for: .normal) + routineListButton.addAction( + UIAction { [weak self] _ in + self?.viewModel.action(input: .selectRoutineListDate) + }, for: .touchUpInside) routineScrollView.showsVerticalScrollIndicator = false routineScrollView.showsHorizontalScrollIndicator = false @@ -430,10 +433,24 @@ final class HomeView: BaseViewController { } } .store(in: &cancellables) + + viewModel.output.routineListDatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] selectedDate in + guard let self else { return } + + guard let viewModel = DIContainer.shared.resolve(type: RoutineListViewModel.self) + else { return } + + let routineListViewController = RoutineListViewController(viewModel: viewModel, selectedDate: selectedDate) + routineListViewController.hidesBottomBarWhenPushed = true + self.navigationController?.pushViewController(routineListViewController, animated: true) + } + .store(in: &cancellables) } // 해당 날짜의 Routine View를 설정합니다. (없다면 EmptyView) - private func updateRoutineView(routines: [MainRoutine]) { + private func updateRoutineView(routines: [Routine]) { routineStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } @@ -449,7 +466,6 @@ final class HomeView: BaseViewController { for routine in routines { let routineView = RoutineView(routine: routine) - routineView.delegate = self routineStackView.addArrangedSubview(routineView) } } @@ -560,19 +576,6 @@ final class HomeView: BaseViewController { } } -// MARK: RoutineViewDelegate -extension HomeView: RoutineViewDelegate { - func routineView(_ sender: RoutineView, didTapMainRoutineCheckButton mainRoutine: MainRoutine) { - showIndicatorView() - viewModel.action(input: .updateRoutineCompletion(updatedRoutine: mainRoutine)) - } - - func routineView(_ sender: RoutineView, didTapSubRoutineCheckButton subRoutine: SubRoutine) { - showIndicatorView() - viewModel.action(input: .updateRoutineCompletion(updatedRoutine: subRoutine)) - } -} - // MARK: WeekViewDelegate extension HomeView: WeekViewDelegate { func weekView(_ sender: WeekView, didSelectDate date: Date) { diff --git a/Projects/Presentation/Sources/Home/View/RoutineDetailView.swift b/Projects/Presentation/Sources/Home/View/RoutineDetailView.swift deleted file mode 100644 index 0a0e05c0..00000000 --- a/Projects/Presentation/Sources/Home/View/RoutineDetailView.swift +++ /dev/null @@ -1,344 +0,0 @@ -// -// 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 emotionPublisher: AnyPublisher let selectedDatePublisher: AnyPublisher + let routineListDatePublisher: AnyPublisher let fetchRoutineResultPublisher: AnyPublisher - let routinesPublisher: AnyPublisher<[MainRoutine], Never> - let deleteRoutineResultPublisher: AnyPublisher + let routinesPublisher: AnyPublisher<[Routine], Never> let updateRoutineCompletionResultPublisher: AnyPublisher } private(set) var output: Output - private var routines: [String: [MainRoutine]] = [:] + private var routines: [String: [Routine]] = [:] private let nicknameSubject = CurrentValueSubject("") private let emotionSubject = CurrentValueSubject(nil) private let selectedDateSubject = CurrentValueSubject(.now) + private let routineListDateSubject = PassthroughSubject() private let fetchRoutineResultSubject = PassthroughSubject() - private let routinesSubject = CurrentValueSubject<[MainRoutine], Never>([]) - private let selectedRoutineSubject = CurrentValueSubject(nil) - private let deleteRoutineResultSubject = PassthroughSubject() + private let routinesSubject = CurrentValueSubject<[Routine], Never>([]) + private let selectedRoutineSubject = CurrentValueSubject(nil) private let updateRoutineCompletionResultSubject = PassthroughSubject() private let calendar = Calendar.current @@ -64,9 +63,9 @@ final class HomeViewModel: ViewModel { nicknamePublisher: nicknameSubject.eraseToAnyPublisher(), emotionPublisher: emotionSubject.eraseToAnyPublisher(), selectedDatePublisher: selectedDateSubject.eraseToAnyPublisher(), + routineListDatePublisher: routineListDateSubject.eraseToAnyPublisher(), fetchRoutineResultPublisher: fetchRoutineResultSubject.eraseToAnyPublisher(), routinesPublisher: routinesSubject.eraseToAnyPublisher(), - deleteRoutineResultPublisher: deleteRoutineResultSubject.eraseToAnyPublisher(), updateRoutineCompletionResultPublisher: updateRoutineCompletionResultSubject.eraseToAnyPublisher() ) } @@ -85,18 +84,16 @@ final class HomeViewModel: ViewModel { case .selectDate(let date): selectDate(date: date) + case .selectRoutineListDate: + let selectedDate = selectedDateSubject.value + routineListDateSubject.send(selectedDate) + case .fetchRoutines: fetchRoutines() case .selectRoutine(let routine): selectedRoutineSubject.send(routine) - case .deleteDailyRoutine: - deleteDailyRoutine() - - case .deleteAllRoutine: - deleteAllRoutine() - case .updateRoutineCompletion(let updatedRoutine): updateRoutineCompletion(updatedRoutine: updatedRoutine) @@ -190,8 +187,9 @@ final class HomeViewModel: ViewModel { Task { do { let entities = try await routineUseCase.fetchRoutines(startDate: startDate, endDate: endDate) - for (date, routineEntities) in entities { - routines[date] = routineEntities.compactMap({ $0.toMainRoutine() }) + for (date, values) in entities { + let routineEntities = values.routines + routines[date] = routineEntities.compactMap({ $0.toRoutine() }) } fetchRoutineResultSubject.send(true) } catch { @@ -200,51 +198,9 @@ final class HomeViewModel: ViewModel { } } - // 반복 루틴을 삭제합니다. - private func deleteAllRoutine() { - guard let routineId = selectedRoutineSubject.value?.id - else { return } - - Task { - do { - try await routineUseCase.deleteAllRoutine(routineId: routineId) - selectedRoutineSubject.send(nil) - deleteRoutineResultSubject.send(true) - fetchRoutines() - } catch { - deleteRoutineResultSubject.send(false) - } - } - } - - // 당일 루틴을 삭제합니다. - private func deleteDailyRoutine() { - guard let routine = selectedRoutineSubject.value - else { return } - - let deleteSubRoutineEntity = routine.subRoutines.map({ - DeleteSubRoutineEntity(subRoutineId: $0.id, routineCompletionId: $0.completionId) }) - let deleteRoutinEntity = DeleteRoutineEntity( - routineId: routine.id, - routineCompletionId: routine.completionId, - historySeq: routine.historySeq, - performedDate: selectedDateSubject.value.convertToString(dateType: .yearMonthDate), - routineType: routine.routineType, - subRoutineInfosForDelete: deleteSubRoutineEntity) - - Task { - do { - try await routineUseCase.deleteDailyRoutine(routine: deleteRoutinEntity) - deleteRoutineResultSubject.send(true) - fetchRoutines() - } catch { - deleteRoutineResultSubject.send(false) - } - } - } - // 루틴의 완료 여부를 업데이트 합니다. private func updateRoutineCompletion(updatedRoutine: Routine) { + /* let performedDate = selectedDateSubject.value.convertToString(dateType: .yearMonthDate) var routineCompletionEntities: [RoutineCompletionEntity] = [] @@ -303,5 +259,6 @@ final class HomeViewModel: ViewModel { updateRoutineCompletionResultSubject.send(false) } } + */ } } diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift index c8dc9a3a..5670846a 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceProtocol.swift @@ -6,6 +6,6 @@ // protocol OnboardingChoiceProtocol { - var mainTitle: String { get } + var title: String { get } var subTitle: String? { get } } diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift index 6f87681d..368ef0f3 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingChoiceType.swift @@ -8,8 +8,7 @@ import Domain extension OnboardingChoiceType: OnboardingChoiceProtocol { - - var mainTitle: String { + var title: String { switch self { case .morningTime: "아침을 잘 시작하고 싶어요." case .eveningTime: "저녁을 편안하게 마무리하고 싶어요." diff --git a/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift b/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift index 523ebb73..43917912 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/OnboardingType.swift @@ -8,7 +8,6 @@ import Domain extension OnboardingType { - var step: Int { switch self { case .time: 1 diff --git a/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift index 3735ed93..76059f41 100644 --- a/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift +++ b/Projects/Presentation/Sources/Onboarding/Model/RecommendedRoutine.swift @@ -7,13 +7,13 @@ import Domain -public struct RecommendedRoutine: OnboardingChoiceProtocol, Hashable { +public struct RecommendedRoutine: OnboardingChoiceProtocol, RoutineProtocol, Hashable { let id: Int - let mainTitle: String + let title: String let subTitle: String? let subRoutines: [String] let routineCategory: RoutineCategoryType - let routineType: RoutineCategoryType + let routineType: RoutineCategoryType? let routineLevel: RoutineLevelType init( @@ -26,7 +26,7 @@ public struct RecommendedRoutine: OnboardingChoiceProtocol, Hashable { routineLevel: RoutineLevelType ) { self.id = id - self.mainTitle = mainTitle + self.title = mainTitle self.subTitle = subTitle self.subRoutines = subRoutines self.routineCategory = routineCategory diff --git a/Projects/Presentation/Sources/Onboarding/View/Component/OnboardingChoiceButton.swift b/Projects/Presentation/Sources/Onboarding/View/Component/OnboardingChoiceButton.swift index f8e7b50e..d0aef1de 100644 --- a/Projects/Presentation/Sources/Onboarding/View/Component/OnboardingChoiceButton.swift +++ b/Projects/Presentation/Sources/Onboarding/View/Component/OnboardingChoiceButton.swift @@ -53,7 +53,7 @@ final class OnboardingChoiceButton: UIButton { stackView.spacing = Layout.stackViewSpacing stackView.isUserInteractionEnabled = false - mainLabel.text = onboardingChoice.mainTitle + mainLabel.text = onboardingChoice.title mainLabel.font = BitnagilFont(style: .body1, weight: .semiBold).font mainLabel.textColor = BitnagilColor.gray50 diff --git a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift index f39b268f..48ab349f 100644 --- a/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift +++ b/Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift @@ -339,4 +339,7 @@ extension RecommendedRoutineViewController: RoutineCardViewDelegate { routineCreationView.hidesBottomBarWhenPushed = true self.navigationController?.pushViewController(routineCreationView, animated: true) } + + func routineCardView(_ sender: RoutineCardView, didTapEditButton routine: Routine) { } + func routineCardView(_ sender: RoutineCardView, didTapDeleteButton routine: Routine) { } } diff --git a/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift b/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift index bf3ec7fc..95ee5604 100644 --- a/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift +++ b/Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift @@ -117,8 +117,8 @@ final class RoutineCreationViewModel: ViewModel { // TODO: - routine fetch 실패 시 처리 방안 필요 (기획과 논의~) guard let routine = try await routineUseCase.fetchRoutine(routineId: id) else { return } - let subRoutines = routine.subRoutineSearchResultDto.map { $0.subRoutineName } - let weekDay = routine.repeatDay.compactMap { Week(rawValue: $0.rawValue) } + let subRoutines = routine.subRoutineNames + let weekDay = routine.repeatDay.compactMap { Week(rawValue: $0) } let repeatType: RepeatType? if weekDay.isEmpty { diff --git a/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift b/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift new file mode 100644 index 00000000..e6849b06 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift @@ -0,0 +1,164 @@ +// +// RoutineDeleteAlertViewController.swift +// Presentation +// +// Created by 최정인 on 8/20/25. +// + +import Combine +import SnapKit +import UIKit + +final class RoutineDeleteAlertViewController: BaseViewController { + private enum Layout { + static let horizontalMargin: CGFloat = 24 + static let mainLabelTopSpacing: CGFloat = 26 + static let mainLabelHeight: CGFloat = 24 + static let subLabelTopSpacing: CGFloat = 10 + static let dismissButtonTopSpacing: CGFloat = 16 + static let dismissButtonTrailingSpacing: CGFloat = 4 + static let dismissButtonWidth: CGFloat = 48 + static let dismissButtonHeight: CGFloat = 44 + static let buttonStackViewTopSpacing: CGFloat = 24 + static let buttonStackViewSpacing: CGFloat = 12 + static let buttonHeight: CGFloat = 54 + } + + private let mainLabel = UILabel() + private let subLabel = UILabel() + private let dismissButton = UIButton() + private let buttonStackView = UIStackView() + private let confirmButton = UIButton() + private let cancleButton = UIButton() + private let isDeleteAllRoutines: Bool + private var cancellables: Set + var onDismiss: (() -> Void)? + init(viewModel: RoutineListViewModel, isDeleteAllRoutines: Bool) { + self.isDeleteAllRoutines = isDeleteAllRoutines + self.cancellables = [] + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed { + if let routineDeleteViewController = presentingViewController?.presentingViewController { + routineDeleteViewController.dismiss(animated: false) + } + onDismiss?() + } + } + + override func configureAttribute() { + mainLabel.text = "루틴을 삭제하시겠어요?" + mainLabel.font = BitnagilFont(style: .title3, weight: .semiBold).font + mainLabel.textColor = BitnagilColor.gray10 + + let subLabelText = "이 루틴과 관련된 모든 기록이 함께 삭제되며, 삭제 후에는\n되돌릴 수 없습니다. 정말 삭제하시겠어요?" + subLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: subLabelText) + subLabel.numberOfLines = 2 + subLabel.font = BitnagilFont(style: .body2, weight: .medium).font + subLabel.textColor = BitnagilColor.gray40 + + dismissButton.setImage(BitnagilIcon.closeIcon, for: .normal) + dismissButton.addAction( + UIAction { [weak self] _ in + self?.dismissToRootView() + }, + for: .touchUpInside) + + buttonStackView.axis = .horizontal + buttonStackView.spacing = Layout.buttonStackViewSpacing + + var cancleButtonConfiguration = UIButton.Configuration.filled() + cancleButtonConfiguration.baseBackgroundColor = BitnagilColor.gray97 + cancleButtonConfiguration.background.cornerRadius = 12 + cancleButtonConfiguration.attributedTitle = AttributedString( + "취소", + attributes: .init([.font: BitnagilFont(style: .body2, weight: .medium).font])) + cancleButtonConfiguration.baseForegroundColor = BitnagilColor.gray40 + cancleButton.configuration = cancleButtonConfiguration + cancleButton.addAction( + UIAction { [weak self] _ in + self?.dismissToRootView() + }, for: .touchUpInside) + + + var confirmButtonConfiguration = UIButton.Configuration.filled() + confirmButtonConfiguration.baseBackgroundColor = BitnagilColor.gray10 + confirmButtonConfiguration.background.cornerRadius = 12 + confirmButtonConfiguration.attributedTitle = AttributedString( + "삭제", + attributes: .init([.font: BitnagilFont(style: .body2, weight: .medium).font])) + confirmButtonConfiguration.baseForegroundColor = .white + confirmButton.configuration = confirmButtonConfiguration + confirmButton.addAction( + UIAction { [weak self] _ in + guard let self else { return } + self.viewModel.action(input: .deleteRoutine(isDeleteAllRoutines: self.isDeleteAllRoutines)) + }, + for: .touchUpInside) + } + + override func configureLayout() { + view.backgroundColor = .systemBackground + + view.addSubview(mainLabel) + view.addSubview(subLabel) + view.addSubview(dismissButton) + view.addSubview(buttonStackView) + [cancleButton, confirmButton].forEach { + buttonStackView.addArrangedSubview($0) + } + + mainLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.mainLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.height.equalTo(Layout.mainLabelHeight) + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + } + + dismissButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.dismissButtonTopSpacing) + make.trailing.equalToSuperview().inset(Layout.dismissButtonTrailingSpacing) + make.width.equalTo(Layout.dismissButtonWidth) + make.height.equalTo(Layout.dismissButtonHeight) + } + + buttonStackView.snp.makeConstraints { make in + make.top.equalTo(subLabel.snp.bottom).offset(Layout.buttonStackViewTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + make.height.equalTo(Layout.buttonHeight) + } + } + + override func bind() { + viewModel.output.deleteRoutineResultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isDeleteRoutine in + if isDeleteRoutine { + self?.dismissToRootView() + } + } + .store(in: &cancellables) + } + + private func dismissToRootView() { + if let routineDeleteViewController = presentingViewController?.presentingViewController { + dismiss(animated: false) + routineDeleteViewController.dismiss(animated: true) + } else { + dismiss(animated: true) + } + } +} diff --git a/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteViewController.swift b/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteViewController.swift new file mode 100644 index 00000000..b8fb61e8 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineList/View/RoutineDeleteViewController.swift @@ -0,0 +1,140 @@ +// +// RoutineDeleteViewController.swift +// Presentation +// +// Created by 최정인 on 8/20/25. +// + +import Shared +import SnapKit +import UIKit + +final class RoutineDeleteViewController: BaseViewController { + private enum Layout { + static let horizontalMargin: CGFloat = 24 + static let mainLabelTopSpacing: CGFloat = 26 + static let mainLabelHeight: CGFloat = 24 + static let subLabelTopSpacing: CGFloat = 10 + static let dailyDeleteButtonTopSpacing: CGFloat = 24 + static let allDeleteButtonTopSpacing: CGFloat = 12 + static let buttonHeight: CGFloat = 54 + static let deleteAlertViewControllerHeight: CGFloat = 204 + static let deleteAlertViewControllerCornerRadius: CGFloat = 20 + } + + private let mainLabel = UILabel() + private let subLabel = UILabel() + private let dailyDeleteButton = UIButton() + private let allDeleteButton = UIButton() + var onDismiss: (() -> Void)? + override init(viewModel: RoutineListViewModel) { + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isBeingDismissed && presentedViewController == nil { + onDismiss?() + } + } + + override func configureAttribute() { + mainLabel.text = "이 루틴은 반복 설정되어 있어요" + mainLabel.font = BitnagilFont(style: .title3, weight: .semiBold).font + mainLabel.textColor = BitnagilColor.gray10 + + let subLabelText = "오늘만 삭제하거나, 전체 반복 일정에서 모두 삭제할 수\n있습니다. 삭제한 루틴은 되돌릴 수 없어요." + subLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: subLabelText) + subLabel.numberOfLines = 2 + subLabel.font = BitnagilFont(style: .body2, weight: .medium).font + subLabel.textColor = BitnagilColor.gray40 + + var dailyDeleteButtonConfiguration = UIButton.Configuration.filled() + dailyDeleteButtonConfiguration.baseBackgroundColor = BitnagilColor.gray10 + dailyDeleteButtonConfiguration.background.cornerRadius = 12 + dailyDeleteButtonConfiguration.attributedTitle = AttributedString( + "오늘만 삭제", + attributes: .init([.font: BitnagilFont(style: .body2, weight: .medium).font])) + dailyDeleteButtonConfiguration.baseForegroundColor = .white + dailyDeleteButton.configuration = dailyDeleteButtonConfiguration + dailyDeleteButton.addAction( + UIAction { [weak self] _ in + self?.showDeleteAlertViewController(isDeleteAllRoutines: false) + }, + for: .touchUpInside) + + var allDeleteButtonConfiguration = UIButton.Configuration.filled() + allDeleteButtonConfiguration.baseBackgroundColor = BitnagilColor.error10 + allDeleteButtonConfiguration.background.cornerRadius = 12 + allDeleteButtonConfiguration.attributedTitle = AttributedString( + "모든 날짜에서 삭제", + attributes: .init([.font: BitnagilFont(style: .body2, weight: .medium).font])) + allDeleteButtonConfiguration.baseForegroundColor = .white + allDeleteButton.configuration = allDeleteButtonConfiguration + allDeleteButton.addAction( + UIAction { [weak self] _ in + self?.showDeleteAlertViewController(isDeleteAllRoutines: true) + }, + for: .touchUpInside) + } + + override func configureLayout() { + view.backgroundColor = .systemBackground + + view.addSubview(mainLabel) + view.addSubview(subLabel) + view.addSubview(dailyDeleteButton) + view.addSubview(allDeleteButton) + + mainLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.mainLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.height.equalTo(Layout.mainLabelHeight) + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + } + + dailyDeleteButton.snp.makeConstraints { make in + make.top.equalTo(subLabel.snp.bottom).offset(Layout.dailyDeleteButtonTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + make.height.equalTo(Layout.buttonHeight) + } + + allDeleteButton.snp.makeConstraints { make in + make.top.equalTo(dailyDeleteButton.snp.bottom).offset(Layout.allDeleteButtonTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + make.height.equalTo(Layout.buttonHeight) + } + } + + private func showDeleteAlertViewController(isDeleteAllRoutines: Bool) { + let deleteAlertViewController = RoutineDeleteAlertViewController(viewModel: viewModel, isDeleteAllRoutines: isDeleteAllRoutines) + deleteAlertViewController.onDismiss = { [weak self] in + self?.onDismiss?() + } + + if let sheet = deleteAlertViewController.sheetPresentationController { + sheet.prefersGrabberVisible = false + if #available(iOS 16.0, *) { + sheet.detents = [.custom { _ in Layout.deleteAlertViewControllerHeight }] + } else { + sheet.detents = [.medium()] + } + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.largestUndimmedDetentIdentifier = .none + sheet.preferredCornerRadius = Layout.deleteAlertViewControllerCornerRadius + } + present(deleteAlertViewController, animated: false) + } +} diff --git a/Projects/Presentation/Sources/RoutineList/View/RoutineEditAlertViewController.swift b/Projects/Presentation/Sources/RoutineList/View/RoutineEditAlertViewController.swift new file mode 100644 index 00000000..1725bbd3 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineList/View/RoutineEditAlertViewController.swift @@ -0,0 +1,116 @@ +// +// RoutineEditAlertViewController.swift +// Presentation +// +// Created by 최정인 on 8/21/25. +// + +import Shared +import SnapKit +import UIKit + +final class RoutineEditAlertViewController: UIViewController { + private enum Layout { + static let horizontalMargin: CGFloat = 24 + static let mainLabelTopSpacing: CGFloat = 26 + static let mainLabelHeight: CGFloat = 24 + static let subLabelTopSpacing: CGFloat = 10 + static let applyTodayButtonTopSpacing: CGFloat = 24 + static let applyTomorrowButtonTopSpacing: CGFloat = 12 + static let buttonHeight: CGFloat = 54 + } + + private let mainLabel = UILabel() + private let subLabel = UILabel() + private let applyTodayButton = UIButton() + private let applyTomorrowButton = UIButton() + var onDismiss: (() -> Void)? + var goToRoutineCreationView: ((Bool) -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + configureAttribute() + configureLayout() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed { onDismiss?() } + } + + private func configureAttribute() { + mainLabel.text = "변경한 루틴, 언제 시작할까요?" + mainLabel.font = BitnagilFont(style: .title3, weight: .semiBold).font + mainLabel.textColor = BitnagilColor.gray10 + + subLabel.text = "변경된 루틴이 반영되는 시점을 선택해 주세요." + subLabel.font = BitnagilFont(style: .body2, weight: .medium).font + subLabel.textColor = BitnagilColor.gray40 + + var applyTodayButtonConfiguration = UIButton.Configuration.filled() + applyTodayButtonConfiguration.baseBackgroundColor = BitnagilColor.gray10 + applyTodayButtonConfiguration.background.cornerRadius = 12 + applyTodayButtonConfiguration.attributedTitle = AttributedString( + "당일부터 적용", + attributes: .init([.font: BitnagilFont(style: .body2, weight: .medium).font])) + applyTodayButtonConfiguration.baseForegroundColor = .white + applyTodayButton.configuration = applyTodayButtonConfiguration + applyTodayButton.addAction( + UIAction { [weak self] _ in + self?.dismiss(animated: true) { + self?.goToRoutineCreationView?(true) + } + }, + for: .touchUpInside) + + var applyTomorrowButtonConfiguration = UIButton.Configuration.filled() + applyTomorrowButtonConfiguration.baseBackgroundColor = BitnagilColor.gray10 + applyTomorrowButtonConfiguration.background.cornerRadius = 12 + applyTomorrowButtonConfiguration.attributedTitle = AttributedString( + "다음 날부터 적용", + attributes: .init([.font: BitnagilFont(style: .body2, weight: .medium).font])) + applyTomorrowButtonConfiguration.baseForegroundColor = .white + applyTomorrowButton.configuration = applyTomorrowButtonConfiguration + applyTomorrowButton.addAction( + UIAction { [weak self] _ in + self?.dismiss(animated: true) { + self?.goToRoutineCreationView?(false) + } + }, + for: .touchUpInside) + } + + private func configureLayout() { + view.backgroundColor = .systemBackground + view.addSubview(mainLabel) + view.addSubview(subLabel) + view.addSubview(applyTodayButton) + view.addSubview(applyTomorrowButton) + + mainLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(Layout.mainLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.height.equalTo(Layout.mainLabelHeight) + } + + subLabel.snp.makeConstraints { make in + make.top.equalTo(mainLabel.snp.bottom).offset(Layout.subLabelTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + } + + applyTodayButton.snp.makeConstraints { make in + make.top.equalTo(subLabel.snp.bottom).offset(Layout.applyTodayButtonTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + make.height.equalTo(Layout.buttonHeight) + } + + applyTomorrowButton.snp.makeConstraints { make in + make.top.equalTo(applyTodayButton.snp.bottom).offset(Layout.applyTomorrowButtonTopSpacing) + make.leading.equalToSuperview().offset(Layout.horizontalMargin) + make.trailing.equalToSuperview().inset(Layout.horizontalMargin) + make.height.equalTo(Layout.buttonHeight) + } + } +} diff --git a/Projects/Presentation/Sources/RoutineList/View/RoutineListViewController.swift b/Projects/Presentation/Sources/RoutineList/View/RoutineListViewController.swift new file mode 100644 index 00000000..ab4189b5 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineList/View/RoutineListViewController.swift @@ -0,0 +1,265 @@ +// +// RoutineListViewController.swift +// Presentation +// +// Created by 최정인 on 8/18/25. +// + +import Combine +import Shared +import SnapKit +import UIKit + +final class RoutineListViewController: BaseViewController { + private enum Layout { + static let horizontalMargin: CGFloat = 20 + static let weekViewTopSpacing: CGFloat = 58 + static let weekViewHeight: CGFloat = 92 + static let emptyViewCenterYSpacing: CGFloat = 40 + static let emptyViewHeight: CGFloat = 102 + static let routineScrollViewTopSpacing: CGFloat = 16 + static let routineStackViewSpacing: CGFloat = 12 + static let routineStackViewBottomSpacing: CGFloat = 60 + } + + private let weekView: WeekView + private let emptyView = HomeEmptyView() + private let routineScrollView = UIScrollView() + private let routineStackView = UIStackView() + private var routineCardViews: [String: RoutineCardView] = [:] + private var dimmedView: UIView? + private var cancellables: Set + + init(viewModel: RoutineListViewModel, selectedDate: Date) { + self.weekView = WeekView(date: selectedDate) + cancellables = [] + viewModel.action(input: .selectDate(date: selectedDate)) + super.init(viewModel: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.action(input: .fetchRoutineList) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + view.subviews.first(where: { $0.tag == 999 })?.removeFromSuperview() + } + + override func configureAttribute() { + weekView.delegate = self + + emptyView.isHidden = true + emptyView.didTapRegisterRoutineButton = { + guard let routineCreationViewModel = DIContainer.shared.resolve(type: RoutineCreationViewModel.self) + else { fatalError("routineCreationViewModel 의존성이 등록되지 않았습니다.") } + + let routineCreationView = RoutineCreationViewController(viewModel: routineCreationViewModel) + routineCreationView.hidesBottomBarWhenPushed = true + self.navigationController?.pushViewController(routineCreationView, animated: true) + } + + routineScrollView.showsVerticalScrollIndicator = false + + routineStackView.axis = .vertical + routineStackView.spacing = Layout.routineStackViewSpacing + } + + override func configureLayout() { + let safeArea = view.safeAreaLayoutGuide + view.backgroundColor = BitnagilColor.gray99 + configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "루틴 리스트"), backgroundColor: BitnagilColor.gray99) + + view.addSubview(weekView) + view.addSubview(emptyView) + view.addSubview(routineScrollView) + routineScrollView.addSubview(routineStackView) + + weekView.snp.makeConstraints { make in + make.top.equalTo(safeArea).offset(Layout.weekViewTopSpacing) + make.horizontalEdges.equalToSuperview() + make.height.equalTo(Layout.weekViewHeight) + } + + emptyView.snp.makeConstraints { make in + make.centerX.equalTo(safeArea) + make.centerY.equalTo(safeArea).offset(Layout.emptyViewCenterYSpacing) + make.height.equalTo(Layout.emptyViewHeight) + } + + routineScrollView.snp.makeConstraints { make in + make.top.equalTo(weekView.snp.bottom).offset(Layout.routineScrollViewTopSpacing) + make.bottom.equalTo(safeArea) + make.leading.equalTo(safeArea).offset(Layout.horizontalMargin) + make.trailing.equalTo(safeArea).inset(Layout.horizontalMargin) + } + + routineStackView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().inset(Layout.routineStackViewBottomSpacing) + make.width.equalTo(routineScrollView.snp.width) + } + } + + override func bind() { + viewModel.output.fetchRoutinesResultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetchRoutines in + if isFetchRoutines { + self?.viewModel.action(input: .fetchDailyRoutine) + } + } + .store(in: &cancellables) + + viewModel.output.selectedDatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] selectedDate in + self?.weekView.updateWeekDateViews(date: selectedDate) + } + .store(in: &cancellables) + + viewModel.output.routinesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] routines in + self?.updateRoutineStackView(routines: routines) + } + .store(in: &cancellables) + } + + private func updateRoutineStackView(routines: [Routine]) { + routineStackView.arrangedSubviews.forEach { view in + routineStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + routineCardViews.removeAll() + + emptyView.isHidden = !routines.isEmpty + routineScrollView.isHidden = routines.isEmpty + for routine in routines { + let routineCardView = RoutineCardView(routine: routine) + routineCardView.delegate = self + routineCardViews[routine.id] = routineCardView + routineStackView.addArrangedSubview(routineCardView) + } + } + + private func goToRoutineCreationView(routineId: String, isApplyToday: Bool = true) { + guard let routineCreationViewModel = DIContainer.shared.resolve(type: RoutineCreationViewModel.self) + else { fatalError("routineCreationViewModel 의존성이 등록되지 않았습니다.") } + + let routineCreationView = RoutineCreationViewController( + viewModel: routineCreationViewModel, + updateInfo: (routineId, isApplyToday ? .today : .tomorrow)) + routineCreationView.hidesBottomBarWhenPushed = true + self.navigationController?.pushViewController(routineCreationView, animated: true) + } +} + +// MARK: WeekViewDelegate +extension RoutineListViewController: WeekViewDelegate { + func weekView(_ sender: WeekView, didSelectDate date: Date) { + viewModel.action(input: .selectDate(date: date)) + } +} + +// MARK: RoutineCardViewDelegate +extension RoutineListViewController: RoutineCardViewDelegate { + func routineCardView(_ sender: RoutineCardView, didTapPlusButton routine: RecommendedRoutine) { } + + func routineCardView(_ sender: RoutineCardView, didTapEditButton routine: Routine) { + viewModel.action(input: .selectRoutine(routine: routine)) + + guard !routine.repeatDay.isEmpty else { + goToRoutineCreationView(routineId: routine.id) + return + } + + dimmedView?.removeFromSuperview() + + let newDimmedView = UIView() + newDimmedView.backgroundColor = .black.withAlphaComponent(0.7) + newDimmedView.frame = view.bounds + view.addSubview(newDimmedView) + dimmedView = newDimmedView + + let routineEditAlertViewController = RoutineEditAlertViewController() + if let sheet = routineEditAlertViewController.sheetPresentationController { + sheet.prefersGrabberVisible = false + if #available(iOS 16.0, *) { + sheet.detents = [.custom { _ in 250 }] + } else { + sheet.detents = [.medium()] + } + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.preferredCornerRadius = 20 + } + + routineEditAlertViewController.onDismiss = { [weak self] in + self?.dimmedView?.removeFromSuperview() + self?.dimmedView = nil + } + + routineEditAlertViewController.goToRoutineCreationView = { [weak self] isApplyToday in + self?.goToRoutineCreationView(routineId: routine.id, isApplyToday: isApplyToday) + } + + present(routineEditAlertViewController, animated: true) + } + + func routineCardView(_ sender: RoutineCardView, didTapDeleteButton routine: Routine) { + viewModel.action(input: .selectRoutine(routine: routine)) + + dimmedView?.removeFromSuperview() + + let newDimmedView = UIView() + newDimmedView.backgroundColor = .black.withAlphaComponent(0.7) + newDimmedView.frame = view.bounds + view.addSubview(newDimmedView) + dimmedView = newDimmedView + + if routine.repeatDay.isEmpty { + let routineDeleteAlertViewController = RoutineDeleteAlertViewController(viewModel: viewModel, isDeleteAllRoutines: false) + if let sheet = routineDeleteAlertViewController.sheetPresentationController { + sheet.prefersGrabberVisible = false + if #available(iOS 16.0, *) { + sheet.detents = [.custom { _ in 204 }] + } else { + sheet.detents = [.medium()] + } + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.preferredCornerRadius = 20 + } + + routineDeleteAlertViewController.onDismiss = { [weak self] in + self?.dimmedView?.removeFromSuperview() + self?.dimmedView = nil + } + + present(routineDeleteAlertViewController, animated: true) + } else { + let routineDeleteViewController = RoutineDeleteViewController(viewModel: viewModel) + if let sheet = routineDeleteViewController.sheetPresentationController { + sheet.prefersGrabberVisible = false + if #available(iOS 16.0, *) { + sheet.detents = [.custom { _ in 270 }] + } else { + sheet.detents = [.medium()] + } + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.preferredCornerRadius = 20 + } + + routineDeleteViewController.onDismiss = { [weak self] in + self?.dimmedView?.removeFromSuperview() + self?.dimmedView = nil + } + + present(routineDeleteViewController, animated: true) + } + } +} diff --git a/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift b/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift new file mode 100644 index 00000000..b90518e9 --- /dev/null +++ b/Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift @@ -0,0 +1,126 @@ +// +// RoutineListViewModel.swift +// Presentation +// +// Created by 최정인 on 8/18/25. +// + +import Combine +import Domain +import Foundation + +final class RoutineListViewModel: ViewModel { + enum Input { + case fetchRoutineList + case fetchDailyRoutine + case selectDate(date: Date) + case selectRoutine(routine: Routine?) + case deleteRoutine(isDeleteAllRoutines: Bool) + } + + struct Output { + let fetchRoutinesResultPublisher: AnyPublisher + let selectedDatePublisher: AnyPublisher + let routinesPublisher: AnyPublisher<[Routine], Never> + let deleteRoutineResultPublisher: AnyPublisher + } + + private(set) var output: Output + private let fetchRoutinesResultSubject = PassthroughSubject() + private let selectedDateSubject = CurrentValueSubject(Date()) + private let routinesSubject = CurrentValueSubject<[Routine], Never>([]) + private let selectedRoutine = CurrentValueSubject(nil) + private let deleteRoutineResultSubject = PassthroughSubject() + private var routines: [String: [Routine]] = [:] + + private let calendar = Calendar.current + private let routineRepository: RoutineRepositoryProtocol + init(routineRepository: RoutineRepositoryProtocol) { + self.routineRepository = routineRepository + self.output = Output( + fetchRoutinesResultPublisher: fetchRoutinesResultSubject.eraseToAnyPublisher(), + selectedDatePublisher: selectedDateSubject.eraseToAnyPublisher(), + routinesPublisher: routinesSubject.eraseToAnyPublisher(), + deleteRoutineResultPublisher: deleteRoutineResultSubject.eraseToAnyPublisher()) + } + + func action(input: Input) { + switch input { + case .fetchRoutineList: + fetchRoutines() + + case .fetchDailyRoutine: + fetchDailyRoutine() + + case .selectDate(let date): + selectedDateSubject.send(date) + fetchDailyRoutine() + + case .selectRoutine(let routine): + selectedRoutine.value = routine + + case .deleteRoutine(let isDeleteAllRoutines): + deleteRoutine(isDeleteAllRoutines: isDeleteAllRoutines) + } + } + + private func fetchRoutines() { + Task { + do { + let startDate = calculateWeekStartDate(for: selectedDateSubject.value) + let endDate = calendar.date(byAdding: .weekOfYear, value: 1, to: startDate) ?? Date() + let startDateString = startDate.convertToString(dateType: .yearMonthDate) + let endDateString = endDate.convertToString(dateType: .yearMonthDate) + + let routinesDictionary = try await routineRepository.fetchRoutines(from: startDateString, to: endDateString) + for dailyRoutine in routinesDictionary { + let date = dailyRoutine.key + let routine = dailyRoutine.value.routines.map({ $0.toRoutine() }) + self.routines[date] = routine + } + fetchRoutinesResultSubject.send(true) + } catch { + fetchRoutinesResultSubject.send(false) + } + } + } + + // 현재 주의 첫째 날을 계산해줍니다. + private func calculateWeekStartDate(for date: Date) -> Date { + let weekday = calendar.component(.weekday, from: date) + let daysFromMonday = (weekday == 1) ? 6 : weekday - 2 + return calendar.date(byAdding: .day, value: -daysFromMonday, to: date) ?? date + } + + private func fetchDailyRoutine() { + let date = selectedDateSubject.value + let dateKey = date.convertToString(dateType: .yearMonthDate) + + guard let dailyRoutines = routines[dateKey] else { + routinesSubject.send([]) + return + } + routinesSubject.send(dailyRoutines) + } + + private func deleteRoutine(isDeleteAllRoutines: Bool) { + guard let routineId = selectedRoutine.value?.id else { + deleteRoutineResultSubject.send(false) + return + } + + Task { + do { + if isDeleteAllRoutines { + try await routineRepository.deleteAllRoutine(routineId: routineId) + } else { + try await routineRepository.deleteDailyRoutine(routineId: routineId) + } + deleteRoutineResultSubject.send(true) + fetchRoutines() + } catch { + deleteRoutineResultSubject.send(false) + } + } + } +} diff --git a/Projects/Shared/Sources/Extension/Date+.swift b/Projects/Shared/Sources/Extension/Date+.swift index 6f5e3625..c921e6cf 100644 --- a/Projects/Shared/Sources/Extension/Date+.swift +++ b/Projects/Shared/Sources/Extension/Date+.swift @@ -31,6 +31,7 @@ extension Date { public enum DateType { case yearMonthDate + case yearMonthDateShort case yearMonth case dayOfWeek case date @@ -42,6 +43,7 @@ extension Date { var formatString: String { switch self { case .yearMonthDate: "yyyy-MM-dd" + case .yearMonthDateShort: "yy.MM.dd" case .yearMonth: "yyyy년 M월" case .dayOfWeek: "E" case .date: "d"